diff --git a/Cargo.lock b/Cargo.lock index acff39c..b2a9b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,6 +347,7 @@ dependencies = [ "sha2", "tempfile", "thiserror", + "toml", "which", ] @@ -473,6 +474,7 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -480,6 +482,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_yaml_ng" version = "0.10.0" @@ -560,6 +571,45 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "typenum" version = "1.20.1" @@ -629,6 +679,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/pickforge-cli/Cargo.toml b/crates/pickforge-cli/Cargo.toml index 0cd4c8b..58f4b72 100644 --- a/crates/pickforge-cli/Cargo.toml +++ b/crates/pickforge-cli/Cargo.toml @@ -18,12 +18,13 @@ path = "src/lib.rs" clap = { version = "4", features = ["derive"] } directories = "6" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } serde_yaml_ng = "0.10" sha2 = "0.10" thiserror = "2" +tempfile = "3" +toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] } which = "8" [dev-dependencies] assert_cmd = "2" -tempfile = "3" diff --git a/crates/pickforge-cli/src/adapters.rs b/crates/pickforge-cli/src/adapters.rs new file mode 100644 index 0000000..eff52f3 --- /dev/null +++ b/crates/pickforge-cli/src/adapters.rs @@ -0,0 +1,338 @@ +//! Pure integration-pack policy and harness config transformations. + +use std::collections::BTreeSet; +use std::fmt; +use std::path::PathBuf; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Harness { + ClaudeCode, + Codex, + Pi, +} + +impl Harness { + pub const ALL: [Self; 3] = [Self::ClaudeCode, Self::Codex, Self::Pi]; +} + +impl fmt::Display for Harness { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::ClaudeCode => "claude-code", + Self::Codex => "codex", + Self::Pi => "pi", + }) + } +} + +impl FromStr for Harness { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "claude-code" => Ok(Self::ClaudeCode), + "codex" => Ok(Self::Codex), + "pi" => Ok(Self::Pi), + _ => Err(format!("unknown harness {value:?}")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct McpServerSpec { + pub name: String, + pub command: String, + pub args: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IntegrationPack { + pub name: String, + pub version: u32, + pub mcp_servers: Vec, +} + +impl IntegrationPack { + pub fn base() -> Self { + Self { + name: "pickforge-base".into(), + version: 1, + mcp_servers: Vec::new(), + } + } + + pub fn validate(&self) -> Result<(), AdapterError> { + let mut names = BTreeSet::new(); + for server in &self.mcp_servers { + if !server.name.starts_with("pickforge-") + || !server + .name + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_".contains(character)) + { + return Err(AdapterError::InvalidServerName(server.name.clone())); + } + if !names.insert(&server.name) { + return Err(AdapterError::DuplicateServerName(server.name.clone())); + } + if server.command.is_empty() { + return Err(AdapterError::EmptyServerCommand(server.name.clone())); + } + if std::iter::once(&server.command) + .chain(&server.args) + .any(|value| value.chars().any(char::is_control)) + { + return Err(AdapterError::ControlCharacter(server.name.clone())); + } + } + Ok(()) + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum AdapterError { + #[error("MCP server name must begin with 'pickforge-' and contain only ASCII letters, digits, '-' or '_': {0}")] + InvalidServerName(String), + #[error("MCP server name is duplicated: {0}")] + DuplicateServerName(String), + #[error("MCP server command must not be empty: {0}")] + EmptyServerCommand(String), + #[error("MCP server command and arguments must not contain control characters: {0}")] + ControlCharacter(String), + #[error("{0} must contain a JSON object")] + JsonObject(&'static str), + #[error("{0} contains malformed JSON: {1}")] + MalformedJson(&'static str, String), + #[error("{0}.mcpServers must be an object")] + McpServersObject(&'static str), + #[error("Codex config has unbalanced Pickforge block markers")] + UnbalancedMarkers, + #[error("Codex config contains malformed TOML outside the Pickforge block: {0}")] + MalformedToml(String), + #[error("Codex config is incompatible with the generated Pickforge block: {0}")] + GeneratedToml(String), + #[error("Codex config mcp_servers value must be a table")] + McpServersTomlTable, + #[error("Codex config has values whose TOML scope crosses the Pickforge block markers")] + MarkerScope, + #[error( + "Codex config manages {0} outside the Pickforge block; move or remove that entry first" + )] + ManagedTableOutsideBlock(String), +} + +pub fn json_config( + existing: Option<&str>, + pack: &IntegrationPack, + label: &'static str, +) -> Result>, AdapterError> { + pack.validate()?; + if pack.mcp_servers.is_empty() { + return Ok(None); + } + let mut root = match existing { + Some(raw) => serde_json::from_str::(raw) + .map_err(|error| AdapterError::MalformedJson(label, error.to_string()))?, + None => serde_json::json!({}), + }; + let object = root + .as_object_mut() + .ok_or(AdapterError::JsonObject(label))?; + if !object.contains_key("mcpServers") { + object.insert("mcpServers".into(), serde_json::json!({})); + } + let servers = object + .get_mut("mcpServers") + .and_then(serde_json::Value::as_object_mut) + .ok_or(AdapterError::McpServersObject(label))?; + for server in &pack.mcp_servers { + servers.insert( + server.name.clone(), + serde_json::json!({"command": server.command, "args": server.args}), + ); + } + let mut rendered = serde_json::to_vec_pretty(&root).expect("JSON values serialize"); + rendered.push(b'\n'); + Ok(Some(rendered)) +} + +const START: &str = "# >>> pickforge >>>"; +const END: &str = "# <<< pickforge <<<"; + +fn without_managed_servers( + mut table: toml::Table, + names: &[String], + keep_empty_servers: bool, +) -> toml::Table { + let remove_servers = if let Some(toml::Value::Table(servers)) = table.get_mut("mcp_servers") { + for name in names { + servers.remove(name); + } + servers.is_empty() && !keep_empty_servers + } else { + false + }; + if remove_servers { + table.remove("mcp_servers"); + } + table +} + +fn validate_codex_outside_block( + lines: &[&str], + range: Option<(usize, usize)>, + names: &[String], +) -> Result { + let outside = lines + .iter() + .enumerate() + .filter(|(index, _)| !range.is_some_and(|(start, end)| *index >= start && *index <= end)) + .map(|(_, line)| *line) + .collect::>() + .join("\n"); + let outside = outside + .parse::() + .map_err(|error| AdapterError::MalformedToml(error.to_string()))?; + if let Some(servers) = outside.get("mcp_servers") { + let servers = servers + .as_table() + .ok_or(AdapterError::McpServersTomlTable)?; + if let Some(name) = names.iter().find(|name| servers.contains_key(*name)) { + return Err(AdapterError::ManagedTableOutsideBlock(name.clone())); + } + } + if let Some((_, end)) = range { + let first_value_after_block = lines[end + 1..] + .iter() + .map(|line| line.trim()) + .find(|line| !line.is_empty() && !line.starts_with('#')); + if first_value_after_block.is_some_and(|line| !line.starts_with('[')) { + return Err(AdapterError::MarkerScope); + } + } + Ok(outside) +} + +fn toml_string(value: &str) -> String { + serde_json::to_string(value).expect("strings serialize") +} + +pub fn codex_config( + existing: Option<&str>, + pack: &IntegrationPack, +) -> Result>, AdapterError> { + pack.validate()?; + if pack.mcp_servers.is_empty() { + return Ok(None); + } + let raw = existing.unwrap_or(""); + let crlf_count = raw.matches("\r\n").count(); + let lf_count = raw.matches('\n').count().saturating_sub(crlf_count); + let newline = if crlf_count > lf_count { "\r\n" } else { "\n" }; + let normalized = raw.replace("\r\n", "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + let raw_lines: Vec<&str> = raw.split_inclusive('\n').collect(); + let starts: Vec = lines + .iter() + .enumerate() + .filter_map(|(i, line)| (*line == START).then_some(i)) + .collect(); + let ends: Vec = lines + .iter() + .enumerate() + .filter_map(|(i, line)| (*line == END).then_some(i)) + .collect(); + if starts.len() > 1 + || ends.len() > 1 + || starts.len() != ends.len() + || starts + .first() + .zip(ends.first()) + .is_some_and(|(a, b)| a >= b) + { + return Err(AdapterError::UnbalancedMarkers); + } + let range = starts.first().zip(ends.first()).map(|(a, b)| (*a, *b)); + let names: Vec = pack.mcp_servers.iter().map(|s| s.name.clone()).collect(); + let outside = validate_codex_outside_block(&lines, range, &names)?; + let mut block = vec![START.to_string()]; + for (index, server) in pack.mcp_servers.iter().enumerate() { + if index > 0 { + block.push(String::new()); + } + block.push(format!("[mcp_servers.{}]", toml_string(&server.name))); + block.push(format!("command = {}", toml_string(&server.command))); + block.push(format!( + "args = [{}]", + server + .args + .iter() + .map(|arg| toml_string(arg)) + .collect::>() + .join(", ") + )); + } + block.push(END.to_string()); + let block = block.join(newline); + let output = if let Some((start, end)) = range { + let mut output = String::new(); + for line in &raw_lines[..start] { + output.push_str(line); + } + output.push_str(&block); + if raw_lines.get(end).is_some_and(|line| line.ends_with('\n')) || end + 1 < raw_lines.len() + { + output.push_str(newline); + } + for line in &raw_lines[end + 1..] { + output.push_str(line); + } + output + } else if raw.is_empty() { + format!("{block}{newline}") + } else if normalized.ends_with("\n\n") { + format!("{raw}{block}{newline}") + } else if normalized.ends_with('\n') { + format!("{raw}{newline}{block}{newline}") + } else { + format!("{raw}{newline}{newline}{block}{newline}") + }; + let complete = output + .parse::() + .map_err(|error| AdapterError::GeneratedToml(error.to_string()))?; + if without_managed_servers(complete, &names, outside.contains_key("mcp_servers")) != outside { + return Err(AdapterError::MarkerScope); + } + Ok(Some(output.into_bytes())) +} + +pub(crate) fn target_for(harness: Harness, env: &crate::Environment) -> Result { + let home = || { + env.home_dir() + .ok_or_else(|| "no home directory could be resolved".to_string()) + }; + Ok(match harness { + Harness::ClaudeCode => home()?.join(".claude.json"), + Harness::Pi => home()?.join(".config").join("mcp").join("mcp.json"), + Harness::Codex => match env.var("CODEX_HOME") { + Some(value) if !value.is_empty() => { + let path = PathBuf::from(value); + if !path.is_absolute() { + return Err(format!( + "CODEX_HOME must be an absolute path, got {:?}", + path + )); + } + path.join("config.toml") + } + _ => home()?.join(".codex").join("config.toml"), + }, + }) +} diff --git a/crates/pickforge-cli/src/init.rs b/crates/pickforge-cli/src/init.rs new file mode 100644 index 0000000..04e3bc4 --- /dev/null +++ b/crates/pickforge-cli/src/init.rs @@ -0,0 +1,454 @@ +//! Read-only init planning and transactional orchestration. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::adapters::{self, Harness, IntegrationPack}; +use crate::transaction::{self, FilePlan}; +use crate::{project, state, Environment}; + +pub const INIT_SCHEMA_VERSION: u32 = 1; +const MAX_STATE_ARTIFACT_BYTES: u64 = 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct InitRequest { + pub project_dir: PathBuf, + pub harnesses: Vec, + pub pack: IntegrationPack, +} + +impl InitRequest { + pub fn new(project_dir: impl Into) -> Self { + Self { + project_dir: project_dir.into(), + harnesses: Harness::ALL.to_vec(), + pack: IntegrationPack::base(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PackReport { + pub name: String, + pub version: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ActionKind { + Create, + Update, + Unchanged, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InitAction { + pub target: String, + pub action: ActionKind, + pub backup_needed: bool, + pub summary: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub server_names: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InitPlanReport { + pub schema_version: u32, + pub project_path: String, + pub project_id: String, + pub state_dir: String, + pub pack: PackReport, + pub harnesses: Vec, + pub actions: Vec, +} + +#[derive(Debug)] +pub struct InitPlan { + pub report: InitPlanReport, + files: Vec, +} + +#[derive(Debug, Error)] +pub enum InitError { + #[error("project directory does not exist: {0}")] + MissingProject(String), + #[error("project path is not a directory: {0}")] + NotDirectory(String), + #[error("Flutter project validation failed: {0}")] + Framework(#[from] project::FrameworkError), + #[error("project identity failed: {0}")] + Identity(#[from] project::ProjectIdentityError), + #[error("state directory resolution failed: {0}")] + State(#[from] state::StateError), + #[error("init has conflicts:\n{0}")] + Conflicts(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ApplyState { + Success, + NoOp, + FailedRolledBack, + FailedPartial, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyReport { + pub schema_version: u32, + pub outcome: ApplyState, + pub changed: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub backup_paths: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub rollback_residuals: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Receipt<'a> { + schema_version: u32, + project_path: &'a str, + project_id: &'a str, + pack: PackReport, + harnesses: &'a [Harness], +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ExistingReceipt { + schema_version: u32, + project_path: String, + project_id: String, +} + +fn validate_existing_receipt( + bytes: &[u8], + expected_path: &str, + expected_id: &str, +) -> Result<(), String> { + let receipt: ExistingReceipt = serde_json::from_slice(bytes).map_err(|error| { + format!("existing project receipt is not owned Pickforge state: {error}") + })?; + if receipt.schema_version != INIT_SCHEMA_VERSION { + return Err(format!( + "existing project receipt uses unsupported schema version {}", + receipt.schema_version + )); + } + if receipt.project_path != expected_path || receipt.project_id != expected_id { + return Err( + "existing project receipt belongs to a different project; choose another PICKFORGE_HOME" + .to_string(), + ); + } + Ok(()) +} + +fn recoverable_state_artifacts( + state_dir: &Path, + expected_path: &str, + expected_id: &str, +) -> Result { + let entries = std::fs::read_dir(state_dir).map_err(|error| { + format!( + "could not inspect state directory {}: {error}", + state_dir.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "could not inspect state directory {}: {error}", + state_dir.display() + ) + })?; + let file_type = entry.file_type().map_err(|error| { + format!( + "could not inspect state artifact {}: {error}", + entry.path().display() + ) + })?; + if file_type.is_symlink() { + return Ok(false); + } + let metadata = entry.metadata().map_err(|error| { + format!( + "could not inspect state artifact {}: {error}", + entry.path().display() + ) + })?; + if !metadata.is_file() || metadata.len() > MAX_STATE_ARTIFACT_BYTES { + return Ok(false); + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + return Ok(false); + }; + if name.starts_with(".pickforge-tmp-") { + continue; + } + if name.starts_with("project.json.pickforge-backup-") { + let (_, bytes) = transaction::inspect_file(entry.path(), true).map_err(|error| { + format!( + "could not safely read state backup {}: {error}", + entry.path().display() + ) + })?; + let bytes = bytes + .ok_or_else(|| format!("state backup {} disappeared", entry.path().display()))?; + validate_existing_receipt(&bytes, expected_path, expected_id)?; + continue; + } + return Ok(false); + } + Ok(true) +} + +fn normalized_harnesses(selected: &[Harness]) -> Vec { + Harness::ALL + .into_iter() + .filter(|harness| selected.contains(harness)) + .collect() +} + +fn action( + path: &Path, + file: &FilePlan, + summary: String, + server_names: Vec, + warning: Option, +) -> InitAction { + InitAction { + target: path.to_string_lossy().into_owned(), + action: if !file.is_changed() { + ActionKind::Unchanged + } else if file.is_create() { + ActionKind::Create + } else { + ActionKind::Update + }, + backup_needed: file.is_changed() && !file.is_create(), + summary, + server_names, + warning, + } +} + +pub fn plan_init(request: &InitRequest, env: &Environment) -> Result { + let canonical = project::canonical_project_path(&request.project_dir); + let metadata = std::fs::metadata(&canonical) + .map_err(|_| InitError::MissingProject(canonical.to_string_lossy().into_owned()))?; + if !metadata.is_dir() { + return Err(InitError::NotDirectory( + canonical.to_string_lossy().into_owned(), + )); + } + project::detect_flutter(&canonical)?; + let project_id = project::derive_project_id(&canonical)?; + let root = state::state_root(env)?; + let state_dir = state::project_state_dir(&root, &project_id); + request + .pack + .validate() + .map_err(|error| InitError::Conflicts(error.to_string()))?; + let harnesses = normalized_harnesses(&request.harnesses); + let mut files = Vec::new(); + let mut actions = Vec::new(); + let mut conflicts = Vec::new(); + let server_names = request + .pack + .mcp_servers + .iter() + .map(|server| server.name.clone()) + .collect::>(); + + if !request.pack.mcp_servers.is_empty() { + for harness in Harness::ALL + .into_iter() + .filter(|harness| harnesses.contains(harness)) + { + let target = match adapters::target_for(harness, env) { + Ok(path) => path, + Err(error) => { + conflicts.push(error); + continue; + } + }; + let planned = transaction::inspect_file(target.clone(), true); + let (snapshot, existing) = match planned { + Ok(value) => value, + Err(error) => { + conflicts.push(error.to_string()); + continue; + } + }; + let text = existing + .as_deref() + .map(|bytes| std::str::from_utf8(bytes).expect("preflight validated UTF-8")); + let transformed = match harness { + Harness::ClaudeCode => { + adapters::json_config(text, &request.pack, "Claude Code config") + } + Harness::Pi => adapters::json_config(text, &request.pack, "Pi MCP config"), + Harness::Codex => adapters::codex_config(text, &request.pack), + }; + match transformed { + Ok(Some(desired)) => { + let file = match snapshot.with_desired(desired) { + Ok(file) => file, + Err(error) => { + conflicts.push(error.to_string()); + continue; + } + }; + let warning = (harness == Harness::Pi).then(|| { + "Core Pi has no built-in MCP; this config requires pi-mcp-adapter." + .to_string() + }); + actions.push(action( + file.path(), + &file, + format!("Configure {harness} MCP servers"), + server_names.clone(), + warning, + )); + files.push(file); + } + Ok(None) => {} + Err(error) => conflicts.push(format!("{}: {error}", snapshot.path().display())), + } + } + } + + let project_path = canonical + .to_str() + .ok_or(project::ProjectIdentityError::NonUtf8Path)? + .to_string(); + let receipt = Receipt { + schema_version: INIT_SCHEMA_VERSION, + project_path: &project_path, + project_id: &project_id, + pack: PackReport { + name: request.pack.name.clone(), + version: request.pack.version, + }, + harnesses: &harnesses, + }; + let mut receipt_bytes = serde_json::to_vec_pretty(&receipt).expect("receipt is serializable"); + receipt_bytes.push(b'\n'); + let receipt_path = state_dir.join("project.json"); + let mut reported_state_dir = state_dir.clone(); + match transaction::plan_file(receipt_path.clone(), receipt_bytes, true) { + Ok((file, existing)) => { + let physical_state_dir = file + .path() + .parent() + .expect("receipt target always has a parent"); + let receipt_conflict = if let Some(bytes) = existing { + validate_existing_receipt(&bytes, &project_path, &project_id).err() + } else if physical_state_dir.is_dir() { + match recoverable_state_artifacts(physical_state_dir, &project_path, &project_id) { + Ok(false) => Some(format!( + "state directory {} is non-empty but has no Pickforge project receipt", + physical_state_dir.display() + )), + Ok(true) => None, + Err(error) => Some(error), + } + } else { + None + }; + if let Some(conflict) = receipt_conflict { + conflicts.push(conflict); + } else { + reported_state_dir = physical_state_dir.to_path_buf(); + actions.push(action( + file.path(), + &file, + "Write external project receipt".into(), + vec![], + None, + )); + files.push(file); + } + } + Err(error) => conflicts.push(error.to_string()), + } + if !conflicts.is_empty() { + return Err(InitError::Conflicts(conflicts.join("\n"))); + } + + Ok(InitPlan { + report: InitPlanReport { + schema_version: INIT_SCHEMA_VERSION, + project_path, + project_id, + state_dir: reported_state_dir.to_string_lossy().into_owned(), + pack: PackReport { + name: request.pack.name.clone(), + version: request.pack.version, + }, + harnesses, + actions, + }, + files, + }) +} + +pub fn apply_init(plan: &InitPlan, backup_stamp: &str) -> ApplyReport { + let changed = plan.files.iter().any(FilePlan::is_changed); + if !changed { + return ApplyReport { + schema_version: INIT_SCHEMA_VERSION, + outcome: ApplyState::NoOp, + changed: false, + backup_paths: vec![], + rollback_residuals: vec![], + error: None, + }; + } + match transaction::apply_files(&plan.files, backup_stamp) { + Ok(backups) => ApplyReport { + schema_version: INIT_SCHEMA_VERSION, + outcome: ApplyState::Success, + changed: true, + backup_paths: backups + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + rollback_residuals: vec![], + error: None, + }, + Err(failure) => ApplyReport { + schema_version: INIT_SCHEMA_VERSION, + outcome: if failure.rolled_back { + ApplyState::FailedRolledBack + } else { + ApplyState::FailedPartial + }, + changed: !failure.rolled_back, + backup_paths: failure + .backup_paths + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + rollback_residuals: failure + .residual_paths + .into_iter() + .map(|path| path.to_string_lossy().into_owned()) + .collect(), + error: Some(failure.error), + }, + } +} diff --git a/crates/pickforge-cli/src/lib.rs b/crates/pickforge-cli/src/lib.rs index 9b6933d..7236eff 100644 --- a/crates/pickforge-cli/src/lib.rs +++ b/crates/pickforge-cli/src/lib.rs @@ -3,14 +3,18 @@ //! The library owns all diagnostics; the `pickforge` binary is a thin adapter //! that parses arguments, renders a report, and maps readiness to an exit code. +pub mod adapters; pub mod doctor; pub mod env; +pub mod init; pub mod project; pub mod render; pub mod report; pub mod state; mod tools; +pub mod transaction; pub use doctor::diagnose; pub use env::Environment; +pub use init::{apply_init, plan_init, ApplyReport, InitPlan, InitPlanReport, InitRequest}; pub use report::{Check, CheckStatus, DoctorReport, ProjectInfo, SCHEMA_VERSION}; diff --git a/crates/pickforge-cli/src/main.rs b/crates/pickforge-cli/src/main.rs index 2c469f5..e2e9688 100644 --- a/crates/pickforge-cli/src/main.rs +++ b/crates/pickforge-cli/src/main.rs @@ -1,8 +1,12 @@ use std::path::PathBuf; use std::process::ExitCode; +use std::time::{SystemTime, UNIX_EPOCH}; use clap::{Parser, Subcommand}; -use pickforge_cli::{diagnose, render, Environment}; +use pickforge_cli::adapters::Harness; +use pickforge_cli::init::{ApplyState, InitRequest}; +use pickforge_cli::{apply_init, diagnose, plan_init, render, Environment}; +use serde::Serialize; #[derive(Parser)] #[command( @@ -20,13 +24,43 @@ struct Cli { enum Command { /// Diagnose whether a project is ready for Pickforge (read-only). Doctor { - /// Project directory to diagnose (defaults to the current directory). #[arg(long, value_name = "PATH")] project_dir: Option, - /// Emit the machine-readable report instead of text. #[arg(long)] json: bool, }, + /// Plan or apply experimental harness integration. + Init { + #[arg(long, value_name = "PATH")] + project_dir: Option, + #[arg(long, value_name = "HARNESS", value_parser = ["claude-code", "codex", "pi"])] + harness: Vec, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct InitOutput<'a> { + plan: &'a pickforge_cli::InitPlanReport, + #[serde(skip_serializing_if = "Option::is_none")] + outcome: Option<&'a pickforge_cli::ApplyReport>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct ErrorOutput<'a> { + schema_version: u32, + error: &'a str, +} + +fn json_line(value: &T) -> String { + let mut output = serde_json::to_string_pretty(value).expect("CLI reports serialize"); + output.push('\n'); + output } fn main() -> ExitCode { @@ -51,5 +85,83 @@ fn main() -> ExitCode { ExitCode::FAILURE } } + Command::Init { + project_dir, + harness, + dry_run, + json, + } => { + let project_dir = project_dir + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + let mut request = InitRequest::new(project_dir); + if !harness.is_empty() { + request.harnesses = harness + .iter() + .map(|value| value.parse::().expect("clap validated harness")) + .collect(); + } + let plan = match plan_init(&request, &Environment::from_process()) { + Ok(plan) => plan, + Err(error) => { + let message = error.to_string(); + if json { + print!( + "{}", + json_line(&ErrorOutput { + schema_version: 1, + error: &message + }) + ); + } else { + println!("pickforge init failed: {}", render::terminal_safe(&message)); + } + return ExitCode::FAILURE; + } + }; + if dry_run { + if json { + print!( + "{}", + json_line(&InitOutput { + plan: &plan.report, + outcome: None + }) + ); + } else { + println!( + "{}dry run: no files written", + render::render_init_plan(&plan.report) + ); + } + return ExitCode::SUCCESS; + } + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + let outcome = apply_init(&plan, &stamp); + if json { + print!( + "{}", + json_line(&InitOutput { + plan: &plan.report, + outcome: Some(&outcome) + }) + ); + } else { + print!( + "{}{}", + render::render_init_plan(&plan.report), + render::render_init_outcome(&outcome) + ); + } + if matches!(outcome.outcome, ApplyState::Success | ApplyState::NoOp) { + ExitCode::SUCCESS + } else { + ExitCode::FAILURE + } + } } } diff --git a/crates/pickforge-cli/src/project.rs b/crates/pickforge-cli/src/project.rs index 7481c43..85b7eea 100644 --- a/crates/pickforge-cli/src/project.rs +++ b/crates/pickforge-cli/src/project.rs @@ -32,7 +32,7 @@ fn lexically_absolute(path: &Path) -> PathBuf { } #[cfg(windows)] -fn normalize_windows_canonical_path(path: PathBuf) -> PathBuf { +pub(crate) fn normalize_windows_canonical_path(path: PathBuf) -> PathBuf { use std::ffi::OsString; use std::os::windows::ffi::{OsStrExt, OsStringExt}; @@ -67,7 +67,7 @@ fn normalize_windows_canonical_path(path: PathBuf) -> PathBuf { } #[cfg(not(windows))] -fn normalize_windows_canonical_path(path: PathBuf) -> PathBuf { +pub(crate) fn normalize_windows_canonical_path(path: PathBuf) -> PathBuf { path } diff --git a/crates/pickforge-cli/src/render.rs b/crates/pickforge-cli/src/render.rs index a15b483..6c236f1 100644 --- a/crates/pickforge-cli/src/render.rs +++ b/crates/pickforge-cli/src/render.rs @@ -10,7 +10,7 @@ fn label(status: CheckStatus) -> &'static str { } } -fn terminal_safe(value: &str) -> String { +pub fn terminal_safe(value: &str) -> String { let mut escaped = String::with_capacity(value.len()); for character in value.chars() { if character.is_control() { @@ -30,6 +30,95 @@ pub fn render_json(report: &DoctorReport) -> String { out } +fn action_label(action: crate::init::ActionKind) -> &'static str { + match action { + crate::init::ActionKind::Create => "CREATE", + crate::init::ActionKind::Update => "UPDATE", + crate::init::ActionKind::Unchanged => "UNCHANGED", + } +} + +pub fn render_init_plan(report: &crate::init::InitPlanReport) -> String { + let mut out = String::from("pickforge init\n"); + out.push_str(&format!( + "project: {}\n", + terminal_safe(&report.project_path) + )); + out.push_str(&format!( + "project id: {}\n", + terminal_safe(&report.project_id) + )); + out.push_str(&format!( + "state dir: {}\n", + terminal_safe(&report.state_dir) + )); + out.push_str(&format!( + "pack: {} v{}\n", + terminal_safe(&report.pack.name), + report.pack.version + )); + out.push_str(&format!( + "harnesses: {}\n", + report + .harnesses + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )); + for action in &report.actions { + out.push_str(&format!( + "[{}] {}: {}\n", + action_label(action.action), + terminal_safe(&action.target), + terminal_safe(&action.summary) + )); + if !action.server_names.is_empty() { + out.push_str(&format!( + " servers: {}\n", + action + .server_names + .iter() + .map(|name| terminal_safe(name)) + .collect::>() + .join(", ") + )); + } + if action.backup_needed { + out.push_str(" backup: required\n"); + } + if let Some(warning) = &action.warning { + out.push_str(&format!(" warning: {}\n", terminal_safe(warning))); + } + } + out +} + +pub fn render_init_outcome(report: &crate::init::ApplyReport) -> String { + use crate::init::ApplyState; + + let outcome = match report.outcome { + ApplyState::Success => "success", + ApplyState::NoOp => "no-op", + ApplyState::FailedRolledBack => "failed-rolled-back", + ApplyState::FailedPartial => "failed-partial", + }; + let mut out = format!( + "outcome: {outcome}\nchanged: {}\n", + if report.changed { "yes" } else { "no" } + ); + for path in &report.backup_paths { + out.push_str(&format!("backup: {}\n", terminal_safe(path))); + } + for path in &report.rollback_residuals { + out.push_str(&format!("rollback residual: {}\n", terminal_safe(path))); + } + if let Some(error) = &report.error { + out.push_str(&format!("error: {}\n", terminal_safe(error))); + } + out +} + pub fn render_text(report: &DoctorReport) -> String { let mut out = String::from("pickforge doctor\n"); out.push_str(&format!( diff --git a/crates/pickforge-cli/src/transaction.rs b/crates/pickforge-cli/src/transaction.rs new file mode 100644 index 0000000..59bfd19 --- /dev/null +++ b/crates/pickforge-cli/src/transaction.rs @@ -0,0 +1,693 @@ +//! Fail-closed snapshots and transactional atomic file replacement. + +use std::ffi::OsString; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; + +use sha2::{Digest, Sha256}; +use tempfile::Builder; +use thiserror::Error; + +const MAX_CONFIG_BYTES: u64 = 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Snapshot { + Missing, + Present { + hash: [u8; 32], + mode: Option, + readonly: bool, + }, +} + +#[derive(Debug, Clone)] +pub struct FilePlan { + pub(crate) path: PathBuf, + pub(crate) desired: Vec, + snapshot: Snapshot, +} + +impl FilePlan { + pub fn path(&self) -> &Path { + &self.path + } + pub fn is_changed(&self) -> bool { + !matches!(&self.snapshot, Snapshot::Present { hash, .. } if *hash == digest(&self.desired)) + } + pub fn is_create(&self) -> bool { + matches!(self.snapshot, Snapshot::Missing) + } + pub(crate) fn with_desired(mut self, desired: Vec) -> Result { + self.desired = desired; + refuse_changed_readonly(&self)?; + Ok(self) + } +} + +#[derive(Debug, Error)] +pub enum TransactionError { + #[error("unsafe target {path}: {reason}")] + Unsafe { path: PathBuf, reason: String }, + #[error("could not inspect {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: io::Error, + }, +} + +#[derive(Debug)] +pub struct ApplyFailure { + pub error: String, + pub rolled_back: bool, + pub backup_paths: Vec, + pub residual_paths: Vec, +} + +fn digest(bytes: &[u8]) -> [u8; 32] { + Sha256::digest(bytes).into() +} + +fn resolve_target_parent(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|source| TransactionError::Io { + path: path.into(), + source, + })? + .join(path) + }; + let file_name = absolute + .file_name() + .ok_or_else(|| unsafe_target(path, "target has no filename"))? + .to_os_string(); + let mut cursor = absolute + .parent() + .ok_or_else(|| unsafe_target(path, "target has no parent"))?; + let mut missing: Vec = Vec::new(); + let resolved = loop { + match fs::symlink_metadata(cursor) { + Ok(metadata) if !metadata.is_dir() && !metadata.file_type().is_symlink() => { + return Err(unsafe_target(path, "a parent path is not a directory")); + } + Ok(_) => { + let canonical = + fs::canonicalize(cursor).map_err(|source| TransactionError::Io { + path: cursor.into(), + source, + })?; + break crate::project::normalize_windows_canonical_path(canonical); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + missing.push( + cursor + .file_name() + .ok_or_else(|| unsafe_target(path, "target has no existing ancestor"))? + .to_os_string(), + ); + cursor = cursor + .parent() + .ok_or_else(|| unsafe_target(path, "target has no existing ancestor"))?; + } + Err(source) => { + return Err(TransactionError::Io { + path: cursor.into(), + source, + }); + } + } + }; + let mut target = resolved; + for component in missing.into_iter().rev() { + target.push(component); + } + target.push(file_name); + Ok(target) +} + +fn inspect_ancestors(path: &Path) -> Result<(), TransactionError> { + let mut cursor = path.parent(); + while let Some(parent) = cursor { + match fs::symlink_metadata(parent) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(unsafe_target(path, "a parent directory is a symbolic link")); + } + Ok(metadata) if !metadata.is_dir() => { + return Err(unsafe_target(path, "a parent path is not a directory")); + } + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(source) => { + return Err(TransactionError::Io { + path: parent.into(), + source, + }); + } + } + cursor = parent.parent(); + } + Ok(()) +} + +fn inspect( + path: &Path, + require_utf8: bool, +) -> Result<(Snapshot, Option>), TransactionError> { + inspect_ancestors(path)?; + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Ok((Snapshot::Missing, None)) + } + Err(source) => { + return Err(TransactionError::Io { + path: path.into(), + source, + }) + } + }; + if metadata.file_type().is_symlink() { + return Err(unsafe_target(path, "symbolic links are not edited")); + } + if !metadata.is_file() { + return Err(unsafe_target(path, "target is not a regular file")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.nlink() > 1 { + return Err(unsafe_target(path, "hardlinked files are not edited")); + } + } + let file = File::open(path).map_err(|source| TransactionError::Io { + path: path.into(), + source, + })?; + let opened_metadata = file.metadata().map_err(|source| TransactionError::Io { + path: path.into(), + source, + })?; + if !opened_metadata.is_file() { + return Err(unsafe_target(path, "target changed while it was inspected")); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + if metadata.dev() != opened_metadata.dev() || metadata.ino() != opened_metadata.ino() { + return Err(unsafe_target(path, "target changed while it was inspected")); + } + } + let mut bytes = Vec::with_capacity( + opened_metadata + .len() + .min(MAX_CONFIG_BYTES.saturating_add(1)) as usize, + ); + file.take(MAX_CONFIG_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|source| TransactionError::Io { + path: path.into(), + source, + })?; + if bytes.len() as u64 > MAX_CONFIG_BYTES { + return Err(unsafe_target(path, "file exceeds the 1 MiB safety limit")); + } + if require_utf8 && std::str::from_utf8(&bytes).is_err() { + return Err(unsafe_target(path, "config is not valid UTF-8")); + } + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + Some(opened_metadata.permissions().mode()) + }; + #[cfg(not(unix))] + let mode = None; + Ok(( + Snapshot::Present { + hash: digest(&bytes), + mode, + readonly: opened_metadata.permissions().readonly(), + }, + Some(bytes), + )) +} + +fn unsafe_target(path: &Path, reason: &str) -> TransactionError { + TransactionError::Unsafe { + path: path.into(), + reason: reason.into(), + } +} + +#[cfg(windows)] +fn refuse_changed_readonly(file: &FilePlan) -> Result<(), TransactionError> { + if matches!(file.snapshot, Snapshot::Present { readonly: true, .. }) && file.is_changed() { + return Err(unsafe_target( + &file.path, + "read-only files are not edited on Windows", + )); + } + Ok(()) +} + +#[cfg(not(windows))] +fn refuse_changed_readonly(_file: &FilePlan) -> Result<(), TransactionError> { + Ok(()) +} + +pub(crate) fn inspect_file( + path: PathBuf, + require_utf8: bool, +) -> Result<(FilePlan, Option>), TransactionError> { + let path = resolve_target_parent(&path)?; + let (snapshot, existing) = inspect(&path, require_utf8)?; + let desired = existing.clone().unwrap_or_default(); + Ok(( + FilePlan { + path, + desired, + snapshot, + }, + existing, + )) +} + +pub fn plan_file( + path: PathBuf, + desired: Vec, + require_utf8: bool, +) -> Result<(FilePlan, Option>), TransactionError> { + let (file, existing) = inspect_file(path, require_utf8)?; + Ok((file.with_desired(desired)?, existing)) +} + +fn set_mode(path: &Path, mode: Option, readonly: bool) -> io::Result<()> { + #[cfg(unix)] + if let Some(mode) = mode { + use std::os::unix::fs::PermissionsExt; + return fs::set_permissions(path, fs::Permissions::from_mode(mode)); + } + #[cfg(not(unix))] + { + let _ = mode; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_readonly(readonly); + fs::set_permissions(path, permissions)?; + } + let _ = readonly; + Ok(()) +} + +fn create_private_dirs(parent: &Path, created: &mut Vec) -> io::Result<()> { + let mut missing = Vec::new(); + let mut cursor = parent; + loop { + match fs::symlink_metadata(cursor) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(io::Error::other(format!( + "refusing symbolic-link parent {}", + cursor.display() + ))); + } + Ok(metadata) if !metadata.is_dir() => { + return Err(io::Error::other(format!( + "parent path is not a directory: {}", + cursor.display() + ))); + } + Ok(_) => break, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + missing.push(cursor.to_path_buf()); + cursor = cursor + .parent() + .ok_or_else(|| io::Error::other("target has no existing ancestor"))?; + } + Err(error) => return Err(error), + } + } + for directory in missing.into_iter().rev() { + #[cfg(unix)] + { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + let mut builder = fs::DirBuilder::new(); + builder.mode(0o700).create(&directory)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + } + #[cfg(not(unix))] + fs::create_dir(&directory)?; + created.push(directory); + } + Ok(()) +} + +fn atomic_write( + path: &Path, + bytes: &[u8], + mode: Option, + readonly: bool, + created_dirs: &mut Vec, +) -> io::Result<()> { + let parent = path + .parent() + .ok_or_else(|| io::Error::other("target has no parent"))?; + create_private_dirs(parent, created_dirs)?; + let mut temp = Builder::new() + .prefix(".pickforge-tmp-") + .tempfile_in(parent)?; + temp.write_all(bytes)?; + temp.flush()?; + temp.as_file().sync_all()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + temp.as_file() + .set_permissions(fs::Permissions::from_mode(mode.unwrap_or(0o600)))?; + } + #[cfg(not(unix))] + { + let _ = mode; + let mut permissions = temp.as_file().metadata()?.permissions(); + permissions.set_readonly(readonly); + temp.as_file().set_permissions(permissions)?; + } + #[cfg(unix)] + let _ = readonly; + temp.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +fn backup_path(path: &Path, stamp: &str) -> io::Result<(PathBuf, fs::File)> { + let name = path + .file_name() + .ok_or_else(|| io::Error::other("target has no filename"))? + .to_string_lossy(); + for collision in 1usize.. { + let suffix = if collision == 1 { + String::new() + } else { + format!("-{collision}") + }; + let candidate = path.with_file_name(format!("{name}.pickforge-backup-{stamp}{suffix}")); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&candidate) { + Ok(file) => return Ok((candidate, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + unreachable!() +} + +pub fn apply_files(files: &[FilePlan], stamp: &str) -> Result, ApplyFailure> { + apply_files_with(files, stamp, atomic_write) +} + +fn apply_files_with( + files: &[FilePlan], + stamp: &str, + mut write_file: F, +) -> Result, ApplyFailure> +where + F: FnMut(&Path, &[u8], Option, bool, &mut Vec) -> io::Result<()>, +{ + if stamp.is_empty() + || !stamp + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character)) + { + return Err(ApplyFailure { + error: "backup stamp may contain only ASCII letters, digits, '-', '_', and '.'" + .to_string(), + rolled_back: true, + backup_paths: vec![], + residual_paths: vec![], + }); + } + for file in files { + match inspect(&file.path, true) { + Ok((snapshot, _)) if snapshot == file.snapshot => {} + Ok(_) => { + return Err(ApplyFailure { + error: format!("target changed since planning: {}", file.path.display()), + rolled_back: true, + backup_paths: vec![], + residual_paths: vec![], + }) + } + Err(error) => { + return Err(ApplyFailure { + error: error.to_string(), + rolled_back: true, + backup_paths: vec![], + residual_paths: vec![], + }) + } + } + } + let mut backups = Vec::new(); + let mut applied: Vec<(&FilePlan, Option)> = Vec::new(); + let mut created_dirs = Vec::new(); + for file in files.iter().filter(|file| file.is_changed()) { + let (current_snapshot, current_bytes) = match inspect(&file.path, true) { + Ok(current) if current.0 == file.snapshot => current, + Ok(_) => { + return rollback( + format!("target changed during apply: {}", file.path.display()), + &applied, + backups, + &created_dirs, + vec![], + ); + } + Err(error) => { + return rollback(error.to_string(), &applied, backups, &created_dirs, vec![]); + } + }; + let backup = match ¤t_snapshot { + Snapshot::Missing => None, + Snapshot::Present { mode, readonly, .. } => { + let (path, mut output) = match backup_path(&file.path, stamp) { + Ok(backup) => backup, + Err(error) => { + return rollback( + format!("backup failed for {}: {error}", file.path.display()), + &applied, + backups, + &created_dirs, + vec![], + ); + } + }; + let backup_result = (|| -> io::Result<()> { + output.write_all( + current_bytes + .as_deref() + .expect("present snapshot has bytes"), + )?; + output.flush()?; + output.sync_all()?; + drop(output); + set_mode(&path, *mode, *readonly) + })(); + if let Err(error) = backup_result { + let mut residuals = Vec::new(); + if fs::remove_file(&path).is_err() { + residuals.push(path); + } + return rollback( + format!("backup failed for {}: {error}", file.path.display()), + &applied, + backups, + &created_dirs, + residuals, + ); + } + backups.push(path.clone()); + Some(path) + } + }; + let (mode, readonly) = match file.snapshot { + Snapshot::Present { mode, readonly, .. } => (mode, readonly), + Snapshot::Missing => (Some(0o600), false), + }; + if let Err(error) = write_file(&file.path, &file.desired, mode, readonly, &mut created_dirs) + { + return rollback( + format!("write failed for {}: {error}", file.path.display()), + &applied, + backups, + &created_dirs, + vec![], + ); + } + applied.push((file, backup)); + } + Ok(backups) +} + +fn rollback( + error: String, + applied: &[(&FilePlan, Option)], + backup_paths: Vec, + created_dirs: &[PathBuf], + mut residuals: Vec, +) -> Result, ApplyFailure> { + for (file, backup) in applied.iter().rev() { + match backup { + Some(backup) => { + let current_is_ours = inspect(&file.path, true) + .ok() + .is_some_and(|(snapshot, _)| { + matches!(snapshot, Snapshot::Present { hash, .. } if hash == digest(&file.desired)) + }); + if !current_is_ours { + residuals.push(file.path.clone()); + continue; + } + let restored = fs::read(backup).and_then(|bytes| { + let metadata = fs::metadata(backup)?; + #[cfg(unix)] + let mode = { + use std::os::unix::fs::PermissionsExt; + Some(metadata.permissions().mode()) + }; + #[cfg(not(unix))] + let mode = None; + let readonly = metadata.permissions().readonly(); + atomic_write(&file.path, &bytes, mode, readonly, &mut Vec::new()) + }); + if restored.is_err() { + residuals.push(file.path.clone()); + } + } + None => match inspect(&file.path, false) { + Ok((Snapshot::Present { hash, .. }, _)) if hash == digest(&file.desired) => { + if fs::remove_file(&file.path).is_err() { + residuals.push(file.path.clone()); + } + } + Ok((Snapshot::Missing, _)) => {} + Ok(_) | Err(_) => residuals.push(file.path.clone()), + }, + } + } + for directory in created_dirs.iter().rev() { + match fs::remove_dir(directory) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(_) => residuals.push(directory.clone()), + } + } + Err(ApplyFailure { + error, + rolled_back: residuals.is_empty(), + backup_paths, + residual_paths: residuals, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn later_write_failure_rolls_back_update_and_create_and_retains_backup() { + let temp = tempfile::tempdir().unwrap(); + let updated = temp.path().join("updated"); + let created = temp.path().join("created"); + let failed = temp.path().join("failed"); + fs::write(&updated, "old").unwrap(); + let (update, _) = plan_file(updated.clone(), b"new".to_vec(), true).unwrap(); + let (create, _) = plan_file(created.clone(), b"created".to_vec(), true).unwrap(); + let (failure, _) = plan_file(failed, b"failed".to_vec(), true).unwrap(); + let mut writes = 0; + let result = apply_files_with( + &[update, create, failure], + "stamp", + |path, bytes, mode, readonly, dirs| { + writes += 1; + if writes == 3 { + return Err(io::Error::other("injected failure")); + } + atomic_write(path, bytes, mode, readonly, dirs) + }, + ) + .unwrap_err(); + assert!(result.rolled_back, "{result:?}"); + assert_eq!(fs::read_to_string(updated).unwrap(), "old"); + assert!(!created.exists()); + assert_eq!(result.backup_paths.len(), 1); + assert!(result.backup_paths[0].exists()); + assert!(result.residual_paths.is_empty()); + } + + #[test] + fn rollback_does_not_overwrite_a_concurrent_external_edit() { + let temp = tempfile::tempdir().unwrap(); + let updated = temp.path().join("updated"); + let failed = temp.path().join("failed"); + fs::write(&updated, "old").unwrap(); + let (update, _) = plan_file(updated.clone(), b"pickforge".to_vec(), true).unwrap(); + let planned_updated = update.path().to_path_buf(); + let (failure, _) = plan_file(failed, b"failed".to_vec(), true).unwrap(); + let mut writes = 0; + let result = apply_files_with( + &[update, failure], + "stamp", + |path, bytes, mode, readonly, dirs| { + writes += 1; + if writes == 2 { + fs::write(&updated, "external")?; + return Err(io::Error::other("injected failure")); + } + atomic_write(path, bytes, mode, readonly, dirs) + }, + ) + .unwrap_err(); + assert!(!result.rolled_back, "{result:?}"); + assert_eq!(fs::read_to_string(&updated).unwrap(), "external"); + assert_eq!(result.residual_paths, vec![planned_updated]); + assert_eq!(result.backup_paths.len(), 1); + } + + #[cfg(unix)] + #[test] + fn rollback_does_not_follow_a_replacement_symlink_for_a_created_file() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let created = temp.path().join("created"); + let failed = temp.path().join("failed"); + let external = temp.path().join("external"); + fs::write(&external, "external").unwrap(); + let (create, _) = plan_file(created.clone(), b"pickforge".to_vec(), true).unwrap(); + let planned_created = create.path().to_path_buf(); + let (failure, _) = plan_file(failed, b"failed".to_vec(), true).unwrap(); + let mut writes = 0; + let result = apply_files_with( + &[create, failure], + "stamp", + |path, bytes, mode, readonly, dirs| { + writes += 1; + if writes == 2 { + fs::remove_file(&created)?; + symlink(&external, &created)?; + return Err(io::Error::other("injected failure")); + } + atomic_write(path, bytes, mode, readonly, dirs) + }, + ) + .unwrap_err(); + assert!(!result.rolled_back, "{result:?}"); + assert_eq!(fs::read_to_string(&external).unwrap(), "external"); + assert_eq!(result.residual_paths, vec![planned_created]); + } +} diff --git a/crates/pickforge-cli/tests/cli.rs b/crates/pickforge-cli/tests/cli.rs index 769d2a8..06b6406 100644 --- a/crates/pickforge-cli/tests/cli.rs +++ b/crates/pickforge-cli/tests/cli.rs @@ -1,6 +1,7 @@ //! Just enough end-to-end coverage to pin output rendering and exit mapping. use std::path::{Path, PathBuf}; +use std::process::Command as ProcessCommand; use assert_cmd::Command; use tempfile::TempDir; @@ -50,6 +51,42 @@ fn flutter_project(root: &Path) -> PathBuf { project_dir } +fn git(project: &Path, args: &[&str]) -> Vec { + let output = ProcessCommand::new("git") + .args(args) + .current_dir(project) + .output() + .unwrap(); + assert!(output.status.success(), "git {args:?} failed: {output:?}"); + output.stdout +} + +fn snapshot_without_git(root: &Path) -> Vec<(PathBuf, Option>)> { + fn visit(root: &Path, path: &Path, entries: &mut Vec<(PathBuf, Option>)>) { + let mut children = std::fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + children.sort(); + for child in children { + if child.file_name().is_some_and(|name| name == ".git") { + continue; + } + let relative = child.strip_prefix(root).unwrap().to_path_buf(); + if child.is_dir() { + entries.push((relative, None)); + visit(root, &child, entries); + } else { + entries.push((relative, Some(std::fs::read(&child).unwrap()))); + } + } + } + + let mut entries = Vec::new(); + visit(root, root, &mut entries); + entries +} + #[test] fn a_ready_project_prints_a_text_report_and_exits_zero() { let temp = TempDir::new().unwrap(); @@ -132,3 +169,144 @@ fn the_current_directory_is_the_default_project() { .assert() .success(); } + +#[test] +fn init_dry_run_preserves_clean_and_dirty_git_trees_byte_for_byte() { + for dirty in [false, true] { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + git(&project_dir, &["init", "--quiet"]); + git(&project_dir, &["add", "pubspec.yaml"]); + git( + &project_dir, + &[ + "-c", + "user.name=Pickforge Test", + "-c", + "user.email=test@invalid.example", + "commit", + "--quiet", + "-m", + "fixture", + ], + ); + if dirty { + std::fs::write(project_dir.join("dirty.txt"), "user work\n").unwrap(); + } + let mut command = pickforge(temp.path(), &[]); + let status_before = git(&project_dir, &["status", "--porcelain=v1"]); + let tree_before = snapshot_without_git(temp.path()); + let output = command + .args(["init", "--dry-run", "--json", "--project-dir"]) + .arg(&project_dir) + .assert() + .success(); + let value: serde_json::Value = serde_json::from_slice(&output.get_output().stdout).unwrap(); + assert_eq!(value["plan"]["schemaVersion"], 1); + assert!(value.get("outcome").is_none()); + assert_eq!( + git(&project_dir, &["status", "--porcelain=v1"]), + status_before + ); + assert_eq!(snapshot_without_git(temp.path()), tree_before); + assert!(!temp.path().join("state").exists()); + } +} + +#[test] +fn init_apply_and_noop_use_success_exit_codes_and_leave_dirty_project_untouched() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + git(&project_dir, &["init", "--quiet"]); + git(&project_dir, &["add", "pubspec.yaml"]); + git( + &project_dir, + &[ + "-c", + "user.name=Pickforge Test", + "-c", + "user.email=test@invalid.example", + "commit", + "--quiet", + "-m", + "fixture", + ], + ); + std::fs::write(project_dir.join("dirty.txt"), "user work\n").unwrap(); + let status_before = git(&project_dir, &["status", "--porcelain=v1"]); + let tree_before = snapshot_without_git(&project_dir); + for _ in 0..2 { + pickforge(temp.path(), &[]) + .args(["init", "--project-dir"]) + .arg(&project_dir) + .assert() + .success(); + } + assert_eq!( + git(&project_dir, &["status", "--porcelain=v1"]), + status_before + ); + assert_eq!(snapshot_without_git(&project_dir), tree_before); +} + +#[test] +fn init_success_and_noop_output_contracts_are_stable() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + let first = pickforge(temp.path(), &[]) + .args(["init", "--json", "--project-dir"]) + .arg(&project_dir) + .assert() + .success(); + let first: serde_json::Value = serde_json::from_slice(&first.get_output().stdout).unwrap(); + assert_eq!(first["plan"]["schemaVersion"], 1); + assert_eq!(first["plan"]["actions"][0]["action"], "create"); + assert_eq!(first["outcome"]["outcome"], "success"); + assert_eq!(first["outcome"]["changed"], true); + + let second = pickforge(temp.path(), &[]) + .args(["init", "--project-dir"]) + .arg(&project_dir) + .assert() + .success(); + let stdout = String::from_utf8(second.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("[UNCHANGED]"), "{stdout}"); + assert!(stdout.contains("outcome: no-op"), "{stdout}"); + assert!(stdout.contains("changed: no"), "{stdout}"); +} + +#[cfg(unix)] +#[test] +fn init_human_output_escapes_path_control_characters() { + let temp = TempDir::new().unwrap(); + let project_dir = flutter_project(temp.path()); + let unsafe_state = temp.path().join("state\nunsafe"); + let output = pickforge(temp.path(), &[]) + .env("PICKFORGE_HOME", &unsafe_state) + .args(["init", "--dry-run", "--project-dir"]) + .arg(&project_dir) + .assert() + .success(); + let stdout = String::from_utf8(output.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("state\\nunsafe"), "{stdout:?}"); + assert!(!stdout.contains(&unsafe_state.to_string_lossy().into_owned())); +} + +#[test] +fn init_precondition_failure_exits_one_without_writing() { + let temp = TempDir::new().unwrap(); + pickforge(temp.path(), &[]) + .args(["init", "--json", "--project-dir"]) + .arg(temp.path().join("missing")) + .assert() + .code(1); + let human = pickforge(temp.path(), &[]) + .args(["init", "--project-dir"]) + .arg(temp.path().join("mïssing\npath")) + .assert() + .code(1); + let stdout = String::from_utf8(human.get_output().stdout.clone()).unwrap(); + assert!(stdout.contains("mïssing\\npath"), "{stdout:?}"); + assert!(!stdout.contains("\\u{ef}"), "{stdout:?}"); + assert!(!temp.path().join("state").exists()); +} diff --git a/crates/pickforge-cli/tests/init.rs b/crates/pickforge-cli/tests/init.rs new file mode 100644 index 0000000..87da3c3 --- /dev/null +++ b/crates/pickforge-cli/tests/init.rs @@ -0,0 +1,390 @@ +use std::path::Path; +use std::time::SystemTime; + +use pickforge_cli::adapters::{ + codex_config, json_config, AdapterError, Harness, IntegrationPack, McpServerSpec, +}; +#[cfg(windows)] +use pickforge_cli::init::ActionKind; +use pickforge_cli::init::{ApplyReport, ApplyState}; +use pickforge_cli::{apply_init, plan_init, render, Environment, InitRequest}; +use tempfile::TempDir; + +const PUBSPEC: &str = "name: app\ndependencies:\n flutter:\n sdk: flutter\n"; + +fn pack() -> IntegrationPack { + IntegrationPack { + name: "fixture".into(), + version: 7, + mcp_servers: vec![McpServerSpec { + name: "pickforge-helper".into(), + command: "pickforge".into(), + args: vec!["serve".into(), "a b".into()], + }], + } +} + +fn fixture() -> (TempDir, std::path::PathBuf, Environment) { + let temp = TempDir::new().unwrap(); + let project = temp.path().join("app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("pubspec.yaml"), PUBSPEC).unwrap(); + let env = Environment::empty() + .with_home_dir(temp.path().join("home")) + .with_var("PICKFORGE_HOME", temp.path().join("state")); + (temp, project, env) +} + +#[test] +fn empty_pack_only_plans_and_applies_a_deterministic_receipt() { + let (temp, project, env) = fixture(); + let request = InitRequest::new(&project); + let plan = plan_init(&request, &env).unwrap(); + assert_eq!(plan.report.actions.len(), 1); + assert!(!temp.path().join("state").exists()); + let first = apply_init(&plan, "1"); + assert!(first.changed); + let receipt = Path::new(&plan.report.state_dir).join("project.json"); + let bytes = std::fs::read(&receipt).unwrap(); + let mtime = std::fs::metadata(&receipt).unwrap().modified().unwrap(); + let second_plan = plan_init(&request, &env).unwrap(); + let second = apply_init(&second_plan, "2"); + assert!(!second.changed); + assert_eq!(std::fs::read(&receipt).unwrap(), bytes); + assert_eq!( + std::fs::metadata(&receipt).unwrap().modified().unwrap(), + mtime + ); + assert!(first.backup_paths.is_empty()); + assert!(second.backup_paths.is_empty()); + let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!( + value["harnesses"], + serde_json::json!(["claude-code", "codex", "pi"]) + ); +} + +#[test] +fn foreign_or_malformed_receipts_are_never_overwritten() { + for existing in [ + b"not json\n".as_slice(), + br#"{"schemaVersion":1,"projectPath":"/other","projectId":"other"}"#.as_slice(), + ] { + let (_temp, project, env) = fixture(); + let request = InitRequest::new(&project); + let initial_plan = plan_init(&request, &env).unwrap(); + let receipt = Path::new(&initial_plan.report.state_dir).join("project.json"); + std::fs::create_dir_all(receipt.parent().unwrap()).unwrap(); + std::fs::write(&receipt, existing).unwrap(); + let error = plan_init(&request, &env).unwrap_err().to_string(); + assert!(error.contains("receipt"), "{error}"); + assert_eq!(std::fs::read(&receipt).unwrap(), existing); + } +} + +#[test] +fn nonempty_unowned_state_directory_is_refused() { + let (_temp, project, env) = fixture(); + let request = InitRequest::new(&project); + let initial_plan = plan_init(&request, &env).unwrap(); + let state_dir = Path::new(&initial_plan.report.state_dir); + std::fs::create_dir_all(state_dir).unwrap(); + std::fs::write(state_dir.join("foreign.txt"), "user-owned").unwrap(); + let error = plan_init(&request, &env).unwrap_err().to_string(); + assert!(error.contains("non-empty"), "{error}"); + assert_eq!( + std::fs::read_to_string(state_dir.join("foreign.txt")).unwrap(), + "user-owned" + ); +} + +#[test] +fn empty_or_owned_interruption_state_allows_receipt_recovery() { + let (_temp, project, env) = fixture(); + let request = InitRequest::new(&project); + let initial_plan = plan_init(&request, &env).unwrap(); + let state_dir = Path::new(&initial_plan.report.state_dir); + std::fs::create_dir_all(state_dir).unwrap(); + assert!(plan_init(&request, &env).is_ok()); + std::fs::write(state_dir.join(".pickforge-tmp-interrupted"), "partial").unwrap(); + let recovery = plan_init(&request, &env).unwrap(); + assert!(apply_init(&recovery, "recovery").changed); + let receipt = state_dir.join("project.json"); + assert!(receipt.is_file()); + std::fs::rename( + &receipt, + state_dir.join("project.json.pickforge-backup-interrupted"), + ) + .unwrap(); + let backup_recovery = plan_init(&request, &env).unwrap(); + assert!(apply_init(&backup_recovery, "backup-recovery").changed); + assert!(receipt.is_file()); +} + +#[cfg(unix)] +#[test] +fn symlinked_home_and_state_roots_are_resolved_safely() { + use std::os::unix::fs::symlink; + + let (temp, project, _) = fixture(); + let real_home = temp.path().join("real-home"); + let real_state = temp.path().join("real-state"); + std::fs::create_dir(&real_home).unwrap(); + std::fs::create_dir(&real_state).unwrap(); + let linked_home = temp.path().join("linked-home"); + let linked_state = temp.path().join("linked-state"); + symlink(&real_home, &linked_home).unwrap(); + symlink(&real_state, &linked_state).unwrap(); + let env = Environment::empty() + .with_home_dir(&linked_home) + .with_var("PICKFORGE_HOME", &linked_state); + let mut request = InitRequest::new(&project); + request.pack = pack(); + request.harnesses = vec![Harness::ClaudeCode]; + let plan = plan_init(&request, &env).unwrap(); + let receipt_action = plan.report.actions.last().unwrap(); + assert_eq!( + Path::new(&plan.report.state_dir), + Path::new(&receipt_action.target).parent().unwrap() + ); + assert!(apply_init(&plan, "symlinked").changed); + assert!(real_home.join(".claude.json").is_file()); + assert!(real_state.join("projects").is_dir()); +} + +#[test] +fn json_adapters_create_merge_validate_and_are_equivalent_and_idempotent() { + let created = json_config(None, &pack(), "config").unwrap().unwrap(); + let created_value: serde_json::Value = serde_json::from_slice(&created).unwrap(); + assert_eq!( + created_value["mcpServers"]["pickforge-helper"]["command"], + "pickforge" + ); + + let input = r#"{"root":1,"mcpServers":{"foreign":{"command":"x"}}}"#; + let claude = json_config(Some(input), &pack(), "Claude Code config") + .unwrap() + .unwrap(); + let pi = json_config(Some(input), &pack(), "Pi MCP config") + .unwrap() + .unwrap(); + assert_eq!(claude, pi); + assert_eq!( + json_config( + Some(std::str::from_utf8(&claude).unwrap()), + &pack(), + "Claude Code config" + ) + .unwrap() + .unwrap(), + claude + ); + let value: serde_json::Value = serde_json::from_slice(&claude).unwrap(); + assert_eq!(value["root"], 1); + assert_eq!(value["mcpServers"]["foreign"]["command"], "x"); + assert!(json_config(Some("[]"), &pack(), "config").is_err()); + assert!(json_config(Some("{"), &pack(), "config").is_err()); + assert!(json_config(Some(r#"{"mcpServers":1}"#), &pack(), "config").is_err()); + + let mut invalid = pack(); + invalid.mcp_servers[0].name = "pickforge-invalid.name".into(); + assert!(json_config(None, &invalid, "config").is_err()); + let mut duplicate = pack(); + duplicate.mcp_servers.push(duplicate.mcp_servers[0].clone()); + assert!(json_config(None, &duplicate, "config").is_err()); + let mut control = pack(); + control.mcp_servers[0].args.push("bad\u{7f}".into()); + assert!(codex_config(None, &control).is_err()); +} + +#[test] +fn codex_managed_block_creates_and_preserves_surroundings_and_newline_style() { + let created = String::from_utf8(codex_config(None, &pack()).unwrap().unwrap()).unwrap(); + assert!(created.parse::().is_ok()); + assert!(created.starts_with("# >>> pickforge >>>\n")); + assert!(created.contains("[mcp_servers.\"pickforge-helper\"]")); + assert!(created.ends_with("# <<< pickforge <<<\n")); + + let input = "title = \"foreign\"\r\n\r\n# >>> pickforge >>>\r\nold = true\r\n# <<< pickforge <<<\r\n\r\n[other]\r\nx = 1\r\n"; + let rendered = codex_config(Some(input), &pack()).unwrap().unwrap(); + let text = String::from_utf8(rendered.clone()).unwrap(); + assert!(text.starts_with("title = \"foreign\"\r\n\r\n# >>> pickforge >>>")); + assert!(text.ends_with("\r\n\r\n[other]\r\nx = 1\r\n")); + assert!(!text.replace("\r\n", "").contains('\n')); + assert_eq!( + codex_config(Some(&text), &pack()).unwrap().unwrap(), + rendered + ); + assert!(codex_config(Some("# >>> pickforge >>>\n"), &pack()).is_err()); + for foreign in [ + "[mcp_servers.\"pickforge-helper\"]\n", + "[mcp_servers . 'pickforge-helper'] # foreign\n", + "[[\"mcp_servers\".'pickforge-helper'.env]]\n", + "mcp_servers.\"pickforge-helper\".command = \"foreign\"\n", + "mcp_servers = { \"pickforge-helper\" = { command = \"foreign\", args = [] } }\n", + "[mcp_servers]\n\"pickforge-helper\" = { command = \"foreign\" }\n", + ] { + assert!(codex_config(Some(foreign), &pack()).is_err(), "{foreign}"); + } + assert!(codex_config(Some("[mcp_servers.\"pickforge-helperx\"]\n"), &pack()).is_ok()); + assert!(codex_config(Some("[mcp_servers]\n"), &pack()).is_ok()); + assert!(codex_config(Some("not = [valid"), &pack()).is_err()); + assert!(matches!( + codex_config( + Some("mcp_servers = { other = { command = \"z\" } }\n"), + &pack() + ), + Err(AdapterError::GeneratedToml(_)) + )); + + let crossing_scope = "# >>> pickforge >>>\n[mcp_servers.\"pickforge-helper\"]\ncommand = \"old\"\nargs = []\n# <<< pickforge <<<\nmodel = \"gpt\"\n"; + assert!(codex_config(Some(crossing_scope), &pack()).is_err()); + let mixed = "title = \"foreign\"\r\n# >>> pickforge >>>\nold = true\n# <<< pickforge <<<\n[other]\r\nx = 1\r\n"; + let mixed_output = + String::from_utf8(codex_config(Some(mixed), &pack()).unwrap().unwrap()).unwrap(); + assert!(mixed_output.starts_with("title = \"foreign\"\r\n# >>> pickforge >>>")); + assert!(mixed_output.ends_with("\n[other]\r\nx = 1\r\n")); +} + +#[test] +fn planning_nonempty_pack_is_read_only_and_deduplicates_in_fixed_order() { + let (temp, project, env) = fixture(); + let mut request = InitRequest::new(project); + request.pack = pack(); + request.harnesses = vec![Harness::Pi, Harness::ClaudeCode, Harness::Pi]; + let before = SystemTime::now(); + let plan = plan_init(&request, &env).unwrap(); + assert_eq!( + plan.report.harnesses, + vec![Harness::ClaudeCode, Harness::Pi] + ); + assert_eq!(plan.report.actions.len(), 3); + assert!(plan.report.actions.iter().any(|action| action + .warning + .as_deref() + .is_some_and(|warning| warning.contains("pi-mcp-adapter")))); + assert!(!temp.path().join("home").exists()); + assert!(!temp.path().join("state").exists()); + assert!(before.elapsed().is_ok()); +} + +#[test] +fn nonempty_pack_apply_is_byte_and_mtime_stable_on_rerun() { + let (_temp, project, env) = fixture(); + let mut request = InitRequest::new(project); + request.pack = pack(); + let first_plan = plan_init(&request, &env).unwrap(); + let first = apply_init(&first_plan, "first"); + assert!(first.changed); + assert!(first.backup_paths.is_empty()); + let snapshots = first_plan + .report + .actions + .iter() + .map(|action| { + let path = std::path::PathBuf::from(&action.target); + ( + path.clone(), + std::fs::read(&path).unwrap(), + std::fs::metadata(&path).unwrap().modified().unwrap(), + ) + }) + .collect::>(); + let second_plan = plan_init(&request, &env).unwrap(); + let second = apply_init(&second_plan, "second"); + assert!(!second.changed); + assert!(second.backup_paths.is_empty()); + for (path, bytes, mtime) in snapshots { + assert_eq!(std::fs::read(&path).unwrap(), bytes); + assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), mtime); + } +} + +#[test] +fn codex_absolute_home_override_works_without_a_user_home_and_relative_override_refuses() { + let (temp, project, _) = fixture(); + let mut request = InitRequest::new(&project); + request.pack = pack(); + request.harnesses = vec![Harness::Codex]; + let env = Environment::empty() + .with_var("PICKFORGE_HOME", temp.path().join("state")) + .with_var("CODEX_HOME", temp.path().join("codex")); + let plan = plan_init(&request, &env).unwrap(); + assert!(plan.report.actions[0].target.ends_with("config.toml")); + assert!(!temp.path().join("codex").exists()); + + let relative = env.with_var("CODEX_HOME", "relative"); + let error = plan_init(&request, &relative).unwrap_err().to_string(); + assert!( + error.contains("CODEX_HOME must be an absolute path"), + "{error}" + ); + assert!(!temp.path().join("state").exists()); +} + +#[cfg(unix)] +#[test] +fn dangling_state_artifact_symlinks_are_refused_as_foreign_state() { + use std::os::unix::fs::symlink; + + let (_temp, project, env) = fixture(); + let request = InitRequest::new(&project); + let initial = plan_init(&request, &env).unwrap(); + let state_dir = Path::new(&initial.report.state_dir); + std::fs::create_dir_all(state_dir).unwrap(); + symlink("missing", state_dir.join(".pickforge-tmp-dangling")).unwrap(); + let error = plan_init(&request, &env).unwrap_err().to_string(); + assert!(error.contains("non-empty but has no Pickforge project receipt")); + assert!(!error.contains("could not inspect state artifact")); +} + +#[cfg(windows)] +#[test] +#[allow(clippy::permissions_set_readonly_false)] +fn windows_readonly_init_rerun_is_a_noop_without_verbatim_paths() { + let (_temp, project, env) = fixture(); + let mut request = InitRequest::new(project); + request.pack = pack(); + request.harnesses = vec![Harness::ClaudeCode]; + let first = plan_init(&request, &env).unwrap(); + assert!(apply_init(&first, "first").changed); + let config = env.home_dir().unwrap().join(".claude.json"); + let mut permissions = std::fs::metadata(&config).unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&config, permissions).unwrap(); + + let second = plan_init(&request, &env).unwrap(); + assert!(second + .report + .actions + .iter() + .all(|action| !action.target.starts_with("\\\\?\\"))); + assert!(!second.report.state_dir.starts_with("\\\\?\\")); + assert!(second.report.actions.iter().any(|action| { + action.target.ends_with(".claude.json") && action.action == ActionKind::Unchanged + })); + assert!(!apply_init(&second, "second").changed); + + let mut permissions = std::fs::metadata(&config).unwrap().permissions(); + permissions.set_readonly(false); + std::fs::set_permissions(config, permissions).unwrap(); +} + +#[test] +fn failed_apply_reports_render_residuals_and_backups_without_controls() { + let report = ApplyReport { + schema_version: 1, + outcome: ApplyState::FailedPartial, + changed: true, + backup_paths: vec!["backup\npath".into()], + rollback_residuals: vec!["residual\tpath".into()], + error: Some("failed\rreason".into()), + }; + let text = render::render_init_outcome(&report); + assert!(text.contains("outcome: failed-partial"), "{text:?}"); + assert!(text.contains("changed: yes"), "{text:?}"); + assert!(text.contains("backup\\npath"), "{text:?}"); + assert!(text.contains("residual\\tpath"), "{text:?}"); + assert!(text.contains("failed\\rreason"), "{text:?}"); +} diff --git a/crates/pickforge-cli/tests/transaction.rs b/crates/pickforge-cli/tests/transaction.rs new file mode 100644 index 0000000..eeb66f3 --- /dev/null +++ b/crates/pickforge-cli/tests/transaction.rs @@ -0,0 +1,137 @@ +use pickforge_cli::transaction::{apply_files, plan_file}; +use tempfile::TempDir; + +#[test] +fn concurrent_drift_aborts_before_any_write() { + let temp = TempDir::new().unwrap(); + let first = temp.path().join("first.json"); + let second = temp.path().join("second.json"); + std::fs::write(&first, "old-one").unwrap(); + std::fs::write(&second, "old-two").unwrap(); + let (first_plan, _) = plan_file(first.clone(), b"new-one".to_vec(), true).unwrap(); + let (second_plan, _) = plan_file(second.clone(), b"new-two".to_vec(), true).unwrap(); + std::fs::write(&second, "drift").unwrap(); + let failure = apply_files(&[first_plan, second_plan], "stamp").unwrap_err(); + assert!(failure.rolled_back); + assert_eq!(std::fs::read_to_string(first).unwrap(), "old-one"); + assert_eq!(std::fs::read_to_string(second).unwrap(), "drift"); + assert!(failure.backup_paths.is_empty()); +} + +#[test] +fn update_retains_exclusive_backup_and_uses_dash_two_on_collision() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("config.json"); + std::fs::write(&target, "old").unwrap(); + std::fs::write( + temp.path().join("config.json.pickforge-backup-s"), + "occupied", + ) + .unwrap(); + let (plan, _) = plan_file(target.clone(), b"new".to_vec(), true).unwrap(); + let expected_backup = plan + .path() + .with_file_name("config.json.pickforge-backup-s-2"); + let backups = apply_files(&[plan], "s").unwrap(); + assert_eq!(backups, vec![expected_backup]); + assert_eq!(std::fs::read_to_string(&backups[0]).unwrap(), "old"); + assert_eq!(std::fs::read_to_string(target).unwrap(), "new"); +} + +#[test] +fn no_op_creates_no_backup_or_temp_file() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("config.json"); + std::fs::write(&target, "same").unwrap(); + let (plan, _) = plan_file(target, b"same".to_vec(), true).unwrap(); + assert!(apply_files(&[plan], "s").unwrap().is_empty()); + assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 1); +} + +#[cfg(unix)] +#[test] +fn unsafe_targets_fail_closed_and_modes_are_private_or_preserved() { + use std::os::unix::fs::{symlink, MetadataExt, PermissionsExt}; + + let temp = TempDir::new().unwrap(); + let real = temp.path().join("real"); + std::fs::write(&real, "x").unwrap(); + let link = temp.path().join("link"); + symlink(&real, &link).unwrap(); + assert!(plan_file(link, b"y".to_vec(), true).is_err()); + + let hard = temp.path().join("hard"); + std::fs::hard_link(&real, &hard).unwrap(); + assert!(plan_file(real, b"y".to_vec(), true).is_err()); + assert!(plan_file(hard, b"y".to_vec(), true).is_err()); + + let fifo = temp.path().join("directory"); + std::fs::create_dir(&fifo).unwrap(); + assert!(plan_file(fifo, b"y".to_vec(), true).is_err()); + + let existing = temp.path().join("existing"); + std::fs::write(&existing, "old").unwrap(); + std::fs::set_permissions(&existing, std::fs::Permissions::from_mode(0o640)).unwrap(); + let (update, _) = plan_file(existing.clone(), b"new".to_vec(), true).unwrap(); + apply_files(&[update], "s").unwrap(); + assert_eq!(std::fs::metadata(existing).unwrap().mode() & 0o777, 0o640); + + let created = temp.path().join("private").join("nested").join("new.json"); + let (create, _) = plan_file(created.clone(), b"new".to_vec(), true).unwrap(); + apply_files(&[create], "s").unwrap(); + assert_eq!(std::fs::metadata(&created).unwrap().mode() & 0o777, 0o600); + assert_eq!( + std::fs::metadata(created.parent().unwrap()).unwrap().mode() & 0o777, + 0o700 + ); +} + +#[test] +fn invalid_backup_stamp_is_rejected_without_writing() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("config"); + std::fs::write(&target, "old").unwrap(); + let (plan, _) = plan_file(target.clone(), b"new".to_vec(), true).unwrap(); + assert!(apply_files(&[plan], "../escape").is_err()); + assert_eq!(std::fs::read_to_string(target).unwrap(), "old"); +} + +#[cfg(unix)] +#[test] +fn symlinked_parent_is_resolved_before_a_missing_target_is_planned() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let real = temp.path().join("real"); + std::fs::create_dir(&real).unwrap(); + let linked_parent = temp.path().join("linked-parent"); + symlink(&real, &linked_parent).unwrap(); + let (plan, _) = plan_file(linked_parent.join("config"), b"new".to_vec(), true).unwrap(); + apply_files(&[plan], "s").unwrap(); + assert_eq!(std::fs::read_to_string(real.join("config")).unwrap(), "new"); +} + +#[cfg(windows)] +#[test] +fn changed_readonly_target_is_refused_but_identical_content_is_a_noop() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("config"); + std::fs::write(&target, "same").unwrap(); + let mut permissions = std::fs::metadata(&target).unwrap().permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&target, permissions).unwrap(); + assert!(plan_file(target.clone(), b"changed".to_vec(), true).is_err()); + let (noop, _) = plan_file(target.clone(), b"same".to_vec(), true).unwrap(); + assert!(apply_files(&[noop], "s").unwrap().is_empty()); + assert!(std::fs::metadata(target).unwrap().permissions().readonly()); +} + +#[test] +fn oversized_and_non_utf8_configs_are_refused() { + let temp = TempDir::new().unwrap(); + let target = temp.path().join("config"); + std::fs::write(&target, vec![b'x'; 1024 * 1024 + 1]).unwrap(); + assert!(plan_file(target.clone(), vec![], true).is_err()); + std::fs::write(&target, [0xff]).unwrap(); + assert!(plan_file(target, vec![], true).is_err()); +} diff --git a/docs/releases/UNRELEASED.md b/docs/releases/UNRELEASED.md index d6e5a79..e17b4d4 100644 --- a/docs/releases/UNRELEASED.md +++ b/docs/releases/UNRELEASED.md @@ -15,6 +15,16 @@ GitHub release description, then reset it after the release is published. only) overrides the state root, which defaults to `~/.pickforge/pickforge`. Project paths that cannot satisfy the shared UTF-8 project-id contract fail closed without resolving a state directory. +- Experimental, unpublished `pickforge init` foundation adds read-only planning, + dry-run/JSON reports, deterministic external project receipts, and + transactional adapter config writes for Claude Code, Codex, and Pi. The base + pack is intentionally empty in this slice, so normal init only writes the + receipt; real Flutter integration content follows separately. Pi MCP config + requires `pi-mcp-adapter` because core Pi has no built-in MCP support. + Individual files use atomic replacement and in-process failures roll back + completed writes while retaining backups. There is deliberately no durable + journal or daemon: a process interruption can leave a partially applied set; + owned temporary/backup artifacts are recognized so a later rerun converges it. ## Internal/release changes @@ -35,11 +45,12 @@ GitHub release description, then reset it after the release is published. one skips, coverage passes at 82.48% lines, and build passes. - The pinned OSV Scanner v2.3.8 image reports no unfiltered advisories. - `cargo fmt --check`, `cargo clippy --workspace --all-targets --locked -- -D - warnings`, `cargo test --workspace --locked` (29 tests: project/framework - detection, tool and harness discovery on a fake `PATH`, `PICKFORGE_HOME` - handling, project-id parity and path boundary cases, JSON/text safety, and CLI - exit codes). The Windows target also passes cross-target check and clippy; - Windows-native tests run in the CI matrix. + warnings`, and `cargo test --workspace --locked` pass with 56 tests covering + project/framework detection, tool and harness discovery, state and project-id + boundaries, adapter preservation/refusal, transaction rollback and drift, + dry-run, receipt ownership, file modes, idempotency, Git-tree cleanliness, + JSON/text safety, and CLI exits. The Windows MSVC target also passes + cross-target check and clippy; Windows-native tests run in the CI matrix. - Manual smoke runs of `pickforge doctor` and `pickforge doctor --json` against temporary fake Flutter and non-Flutter projects with an isolated `PATH`/`PICKFORGE_HOME`.