diff --git a/atomic-cli/README.md b/atomic-cli/README.md index 6517fec..e7cec69 100644 --- a/atomic-cli/README.md +++ b/atomic-cli/README.md @@ -89,6 +89,19 @@ atomic status # Show short/compact output atomic status --short + +# Emit the versioned integration document used by IDEs +atomic status --json +``` + +`--json` includes the repository root, current view and state, reindex metadata, +and a structured entry for every changed path. The document carries a +`schema_version` so integrations can reject incompatible formats explicitly. + +View metadata is available through the matching integration command: + +```bash +atomic view list --json ``` #### `atomic add ` diff --git a/atomic-cli/src/commands/status.rs b/atomic-cli/src/commands/status.rs index 5014fef..eee1265 100644 --- a/atomic-cli/src/commands/status.rs +++ b/atomic-cli/src/commands/status.rs @@ -102,6 +102,7 @@ use std::path::PathBuf; use clap::Parser; +use serde::Serialize; use atomic_core::types::Base32; use atomic_repository::status::{FileStatus, RepositoryStatus, StatusOptions}; @@ -169,6 +170,10 @@ pub struct Status { #[arg(short = 's', long = "short")] pub short: bool, + /// Emit a versioned JSON document for IDEs and other integrations. + #[arg(long, conflicts_with_all = ["short", "debug_ignore"])] + pub json: bool, + /// Don't show untracked files. /// /// By default, untracked files are shown in the status output. @@ -203,6 +208,7 @@ impl Status { Self { path: None, short: false, + json: false, no_untracked: false, debug_ignore: false, reindex: false, @@ -221,6 +227,12 @@ impl Status { self } + /// Builder: set JSON output mode. + pub fn with_json(mut self, json: bool) -> Self { + self.json = json; + self + } + /// Builder: set whether to hide untracked files. pub fn with_no_untracked(mut self, no_untracked: bool) -> Self { self.no_untracked = no_untracked; @@ -417,6 +429,19 @@ impl Status { Ok(()) } + + /// Print a stable, versioned status document for editor integrations. + fn print_json_format( + &self, + status: &RepositoryStatus, + repo_root: &std::path::Path, + ) -> CliResult<()> { + let output = JsonStatus::new(status, repo_root); + let json = serde_json::to_string_pretty(&output) + .map_err(|error| CliError::Internal(error.into()))?; + println!("{}", json); + Ok(()) + } } impl Default for Status { @@ -501,13 +526,18 @@ impl Command for Status { let start = std::time::Instant::now(); match rw_repo.reindex_working_copy() { Ok(count) => { - print_info(&format!( - "Reindexed {} files in {:.1}s", - count, - start.elapsed().as_secs_f64() - )); + if !self.json { + print_info(&format!( + "Reindexed {} files in {:.1}s", + count, + start.elapsed().as_secs_f64() + )); + } } Err(e) => { + if self.json { + return Err(CliError::Internal(e.into())); + } print_warning(&format!("Reindex failed: {}", e)); } } @@ -534,7 +564,9 @@ impl Command for Status { .map_err(|e| CliError::Internal(e.into()))?; // Print in appropriate format - if self.short { + if self.json { + self.print_json_format(&status, &repo_root) + } else if self.short { self.print_short_format(&status) } else { self.print_long_format(&status) @@ -542,6 +574,78 @@ impl Command for Status { } } +// JSON Output Types + +/// Version of the `atomic status --json` document. +const STATUS_JSON_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct JsonStatus { + schema_version: u32, + repository_root: String, + view: String, + state: Option, + clean: bool, + needs_reindex: bool, + stale_index_count: usize, + entries: Vec, +} + +impl JsonStatus { + fn new(status: &RepositoryStatus, repo_root: &std::path::Path) -> Self { + let mut entries: Vec<_> = status.entries().iter().map(JsonStatusEntry::from).collect(); + entries.sort_by(|left, right| left.path.cmp(&right.path)); + + Self { + schema_version: STATUS_JSON_SCHEMA_VERSION, + repository_root: repo_root.to_string_lossy().into_owned(), + view: status.view().to_string(), + state: status.state().map(|state| state.to_base32()), + clean: entries.is_empty(), + needs_reindex: status.needs_reindex(), + stale_index_count: status.stale_index_count(), + entries, + } + } +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct JsonStatusEntry { + path: String, + status: &'static str, + code: String, + details: Option, +} + +impl From<&atomic_repository::status::FileStatusEntry> for JsonStatusEntry { + fn from(entry: &atomic_repository::status::FileStatusEntry) -> Self { + Self { + path: json_path(entry.path()), + status: json_status_name(entry.status()), + code: entry.status().short_code().to_string(), + details: entry.details().map(str::to_string), + } + } +} + +fn json_path(path: &std::path::Path) -> String { + path.to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") +} + +fn json_status_name(status: FileStatus) -> &'static str { + match status { + FileStatus::Clean => "clean", + FileStatus::Modified => "modified", + FileStatus::Deleted => "deleted", + FileStatus::Untracked => "untracked", + FileStatus::Added => "added", + FileStatus::Conflicted => "conflicted", + FileStatus::TypeChanged => "type_changed", + FileStatus::PermissionsChanged => "permissions_changed", + } +} + // Helper Types /// Output configuration for status display. @@ -922,6 +1026,55 @@ mod tests { assert!(!status.is_clean()); } + #[test] + fn test_json_status_is_versioned_and_sorted() { + let mut status = RepositoryStatus::new("feature".to_string(), Some(Merkle::initial())); + status.add_entry(FileStatusEntry::new( + PathBuf::from("zeta.rs"), + FileStatus::Modified, + )); + status.add_entry(FileStatusEntry::new( + PathBuf::from("alpha.rs"), + FileStatus::Untracked, + )); + + let output = JsonStatus::new(&status, std::path::Path::new("/workspace/project")); + + assert_eq!(output.schema_version, 1); + assert_eq!(output.repository_root, "/workspace/project"); + assert_eq!(output.view, "feature"); + assert!(output.state.is_some()); + assert!(!output.clean); + assert_eq!(output.entries[0].path, "alpha.rs"); + assert_eq!(output.entries[0].status, "untracked"); + assert_eq!(output.entries[1].path, "zeta.rs"); + assert_eq!(output.entries[1].code, "M"); + } + + #[test] + fn test_json_status_escapes_paths() { + let mut status = RepositoryStatus::new("dev".to_string(), None); + status.add_entry(FileStatusEntry::new( + PathBuf::from("line\nbreak.txt"), + FileStatus::Untracked, + )); + + let output = JsonStatus::new(&status, std::path::Path::new("/workspace")); + let json = serde_json::to_string(&output).unwrap(); + + assert!(json.contains("line\\nbreak.txt")); + assert_eq!( + serde_json::from_str::(&json).unwrap()["entries"][0]["path"], + "line\nbreak.txt" + ); + } + + #[test] + fn test_json_and_short_are_mutually_exclusive() { + assert!(Status::try_parse_from(["status", "--json"]).unwrap().json); + assert!(Status::try_parse_from(["status", "--short", "--json"]).is_err()); + } + // Print Format Tests (Output verification) #[test] diff --git a/atomic-cli/src/commands/view/list.rs b/atomic-cli/src/commands/view/list.rs index e4aa32c..3319320 100644 --- a/atomic-cli/src/commands/view/list.rs +++ b/atomic-cli/src/commands/view/list.rs @@ -32,6 +32,7 @@ use std::time::Duration; use clap::Parser; +use serde::Serialize; use atomic_remote::{HttpRemote, HttpRemoteConfig, RemoteViewInfo}; use atomic_repository::Repository; @@ -61,6 +62,10 @@ pub struct List { #[arg(long, short = 's')] pub short: bool, + /// Emit a versioned JSON document for IDEs and other integrations. + #[arg(long, conflicts_with = "short")] + pub json: bool, + /// Show additional details (state hash, change count). /// /// This is now the default behavior. Kept for backward compatibility. @@ -94,6 +99,7 @@ impl List { pub fn new() -> Self { Self { short: false, + json: false, verbose: false, remote: None, identity: None, @@ -106,6 +112,12 @@ impl List { self.verbose = verbose; self } + + /// Builder: set JSON output mode. + pub fn with_json(mut self, json: bool) -> Self { + self.json = json; + self + } } impl List { @@ -128,7 +140,7 @@ impl List { .map(|(name, entry)| (name, entry.url)) .map_err(CliError::Repository)? } else if remote_arg.contains("://") { - (remote_arg.to_string(), remote_arg.to_string()) + (sanitized_remote_label(remote_arg), remote_arg.to_string()) } else { let entry = repo .get_remote(remote_arg) @@ -138,11 +150,13 @@ impl List { (remote_arg.to_string(), entry.url) }; - println!( - "Views on {} ({})", - style_view(&remote_name), - hint(&remote_url) - ); + if !self.json { + println!( + "Views on {} ({})", + style_view(&remote_name), + hint(&remote_url) + ); + } let rt = tokio::runtime::Runtime::new().map_err(|e| { CliError::Internal(anyhow::anyhow!("Failed to create async runtime: {}", e)) @@ -164,15 +178,30 @@ impl List { .map_err(|e| CliError::remote_error(e.to_string(), Some(remote_url.clone()))) })?; - self.print_remote_views(&views); - Ok(()) + self.print_remote_views(&views, &remote_name) } /// Render the remote view listing. - fn print_remote_views(&self, views: &[RemoteViewInfo]) { + fn print_remote_views(&self, views: &[RemoteViewInfo], remote_name: &str) -> CliResult<()> { + if self.json { + let mut json_views: Vec<_> = views + .iter() + .map(JsonView::from_remote) + .collect::>()?; + json_views.sort_by(|left, right| left.name.cmp(&right.name)); + return print_json(&JsonViewList { + schema_version: VIEW_LIST_JSON_SCHEMA_VERSION, + source: "remote", + repository_root: None, + remote: Some(remote_name.to_string()), + current_view: None, + views: json_views, + }); + } + if views.is_empty() { println!("{}", hint("No views found on the remote.")); - return; + return Ok(()); } let mut sorted: Vec<&RemoteViewInfo> = views.iter().collect(); @@ -182,7 +211,7 @@ impl List { for view in sorted { println!(" {}", style_view(&view.name)); } - return; + return Ok(()); } let max_name_len = sorted.iter().map(|v| v.name.len()).max().unwrap_or(0); @@ -217,6 +246,8 @@ impl List { width = max_name_len ); } + + Ok(()) } } @@ -240,6 +271,23 @@ impl Command for List { let views = repo.list_views().map_err(CliError::Repository)?; let current = repo.current_view(); + if self.json { + let mut json_views = Vec::with_capacity(views.len()); + for view in &views { + let info = repo.get_view_info(view).map_err(CliError::Repository)?; + json_views.push(JsonView::from_local(&info, view == current)); + } + json_views.sort_by(|left, right| left.name.cmp(&right.name)); + return print_json(&JsonViewList { + schema_version: VIEW_LIST_JSON_SCHEMA_VERSION, + source: "local", + repository_root: Some(repo_root.to_string_lossy().into_owned()), + remote: None, + current_view: Some(current.to_string()), + views: json_views, + }); + } + if views.is_empty() { println!( "{}", @@ -310,6 +358,96 @@ impl Command for List { } } +// JSON Output Types + +/// Version of the `atomic view list --json` document. +const VIEW_LIST_JSON_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct JsonViewList { + schema_version: u32, + source: &'static str, + repository_root: Option, + remote: Option, + current_view: Option, + views: Vec, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +struct JsonView { + name: String, + current: bool, + scope: String, + parent: Option, + change_count: u64, + own_change_count: Option, + inherited_change_count: Option, + state: Option, + set_id: Option, +} + +impl JsonView { + fn from_local(info: &atomic_repository::ViewInfo, current: bool) -> Self { + Self { + name: info.name.clone(), + current, + scope: info.kind_label().to_string(), + parent: info.parent_name.clone(), + change_count: info + .own_change_count + .saturating_add(info.inherited_change_count), + own_change_count: Some(info.own_change_count), + inherited_change_count: Some(info.inherited_change_count), + state: Some(info.state_base32()), + set_id: None, + } + } + + fn from_remote(info: &RemoteViewInfo) -> CliResult { + let scope = if info.scope.eq_ignore_ascii_case("shared") { + "shared" + } else if info.scope.eq_ignore_ascii_case("draft") { + "draft" + } else { + return Err(CliError::Internal(anyhow::anyhow!( + "Remote view '{}' has unsupported scope '{}'", + info.name, + info.scope + ))); + }; + + Ok(Self { + name: info.name.clone(), + current: false, + scope: scope.to_string(), + parent: info.parent.clone(), + change_count: info.change_count, + own_change_count: None, + inherited_change_count: None, + state: info.state.clone(), + set_id: info.set_id.clone(), + }) + } +} + +fn sanitized_remote_label(remote: &str) -> String { + let Ok(mut url) = url::Url::parse(remote) else { + return remote.to_string(); + }; + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() +} + +fn print_json(output: &JsonViewList) -> CliResult<()> { + let json = + serde_json::to_string_pretty(output).map_err(|error| CliError::Internal(error.into()))?; + println!("{}", json); + Ok(()) +} + // Tests #[cfg(test)] @@ -347,6 +485,7 @@ mod tests { fn test_default() { let cmd = List::default(); assert!(!cmd.short); + assert!(!cmd.json); assert!(!cmd.verbose); } @@ -362,6 +501,80 @@ mod tests { assert!(cmd.verbose); } + #[test] + fn test_json_and_short_are_mutually_exclusive() { + assert!(List::try_parse_from(["list", "--json"]).unwrap().json); + assert!(List::try_parse_from(["list", "--short", "--json"]).is_err()); + } + + #[test] + fn test_local_json_view_contains_editor_metadata() { + let info = atomic_repository::ViewInfo { + name: "feature".to_string(), + state: atomic_core::types::Merkle::initial(), + change_count: 3, + own_change_count: 1, + inherited_change_count: 2, + scope: atomic_core::pristine::ViewScope::Draft, + parent_name: Some("dev".to_string()), + }; + + let view = JsonView::from_local(&info, true); + + assert_eq!(view.name, "feature"); + assert!(view.current); + assert_eq!(view.scope, "draft"); + assert_eq!(view.parent.as_deref(), Some("dev")); + assert_eq!(view.change_count, 3); + assert_eq!(view.own_change_count, Some(1)); + assert_eq!(view.inherited_change_count, Some(2)); + assert!(view.state.is_some()); + } + + #[test] + fn test_remote_json_view_preserves_set_id() { + let info = RemoteViewInfo { + name: "dev".to_string(), + scope: "shared".to_string(), + parent: None, + change_count: 4, + state: Some("STATE".to_string()), + set_id: Some("SET".to_string()), + }; + + let view = JsonView::from_remote(&info).unwrap(); + + assert_eq!(view.name, "dev"); + assert!(!view.current); + assert_eq!(view.set_id.as_deref(), Some("SET")); + assert_eq!(view.own_change_count, None); + } + + #[test] + fn test_remote_json_view_normalizes_and_validates_scope() { + let mut info = RemoteViewInfo { + name: "dev".to_string(), + scope: "DRAFT".to_string(), + parent: None, + change_count: 0, + state: None, + set_id: None, + }; + + assert_eq!(JsonView::from_remote(&info).unwrap().scope, "draft"); + info.scope = "unknown".to_string(); + assert!(JsonView::from_remote(&info).is_err()); + } + + #[test] + fn test_direct_remote_label_redacts_credentials_and_query() { + assert_eq!( + sanitized_remote_label("https://user:secret@example.com/storage?token=value#fragment"), + "https://example.com/storage" + ); + assert_eq!(sanitized_remote_label("origin"), "origin"); + } + // ------------------------------------------------------------------------- // Integration Tests (require temp repository) // ------------------------------------------------------------------------- diff --git a/atomic-cli/tests/ide_json_integration_test.rs b/atomic-cli/tests/ide_json_integration_test.rs new file mode 100644 index 0000000..56fd33d --- /dev/null +++ b/atomic-cli/tests/ide_json_integration_test.rs @@ -0,0 +1,82 @@ +//! Process-level coverage for the JSON contract consumed by IDE integrations. + +use std::path::Path; +use std::process::{Command, Output}; + +use serde_json::Value; +use tempfile::TempDir; + +const ATOMIC_BIN: &str = env!("CARGO_BIN_EXE_atomic"); + +fn atomic(dir: &Path, args: &[&str]) -> Output { + Command::new(ATOMIC_BIN) + .args(args) + .current_dir(dir) + .output() + .expect("run atomic") +} + +fn initialized_repo() -> TempDir { + let dir = TempDir::new().expect("tempdir"); + let output = atomic(dir.path(), &["init"]); + assert!(output.status.success(), "init failed: {output:?}"); + dir +} + +#[test] +fn status_json_exposes_versioned_repository_state() { + let dir = initialized_repo(); + std::fs::write(dir.path().join("file with spaces.txt"), b"hello\n").unwrap(); + + let output = atomic(dir.path(), &["status", "--json"]); + assert!(output.status.success(), "status failed: {output:?}"); + assert!(output.stderr.is_empty(), "unexpected stderr: {output:?}"); + + let json: Value = serde_json::from_slice(&output.stdout).expect("valid status JSON"); + assert_eq!(json["schema_version"], 1); + let canonical_root = dir.path().canonicalize().unwrap(); + assert_eq!( + json["repository_root"], + canonical_root.to_string_lossy().as_ref() + ); + assert_eq!(json["view"], "dev"); + assert!(json["state"].is_string()); + assert_eq!(json["clean"], false); + assert_eq!(json["entries"][0]["path"], "file with spaces.txt"); + assert_eq!(json["entries"][0]["status"], "untracked"); +} + +#[test] +fn view_list_json_marks_the_current_view() { + let dir = initialized_repo(); + + let output = atomic(dir.path(), &["view", "list", "--json"]); + assert!(output.status.success(), "view list failed: {output:?}"); + assert!(output.stderr.is_empty(), "unexpected stderr: {output:?}"); + + let json: Value = serde_json::from_slice(&output.stdout).expect("valid view JSON"); + assert_eq!(json["schema_version"], 1); + assert_eq!(json["source"], "local"); + assert_eq!(json["current_view"], "dev"); + assert_eq!(json["views"][0]["name"], "dev"); + assert_eq!(json["views"][0]["current"], true); + assert_eq!(json["views"][0]["scope"], "shared"); +} + +#[test] +fn json_and_short_output_cannot_be_combined() { + let dir = initialized_repo(); + + for args in [ + &["status", "--short", "--json"][..], + &["view", "list", "--short", "--json"][..], + ] { + let output = atomic(dir.path(), args); + assert_eq!(output.status.code(), Some(2), "{args:?}: {output:?}"); + assert!(output.stdout.is_empty(), "{args:?}: {output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("error: conflicting-args"), + "{args:?}: {output:?}" + ); + } +} diff --git a/atomic-remote/src/http/bare.rs b/atomic-remote/src/http/bare.rs index d1ad176..4261ad8 100644 --- a/atomic-remote/src/http/bare.rs +++ b/atomic-remote/src/http/bare.rs @@ -43,9 +43,21 @@ impl HttpRemote { .text() .await .map_err(|e| RemoteError::connection_failed(&url, e))?; - Ok(text.lines().filter_map(RemoteViewInfo::parse).collect()) + text.lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(index, line)| { + RemoteViewInfo::parse_strict(line).map_err(|reason| { + RemoteError::protocol(format!( + "Invalid view inventory row {}: {}", + index + 1, + reason + )) + }) + }) + .collect() } - StatusCode::NOT_FOUND => Ok(Vec::new()), + StatusCode::NOT_FOUND => Err(RemoteError::repo_not_found(&url)), s => Err(RemoteError::http( s.as_u16(), response.text().await.unwrap_or_default(), @@ -79,4 +91,11 @@ mod tests { "https://h/workspaces/w/projects/p/refs/views" ); } + + #[test] + fn strict_view_inventory_rejects_malformed_rows() { + assert!(RemoteViewInfo::parse_strict("dev\tshared\t-\tnot-a-count\t-").is_err()); + assert!(RemoteViewInfo::parse_strict("dev\tunknown\t-\t0\t-").is_err()); + assert!(RemoteViewInfo::parse_strict(r#"{"workspace":"w"}"#).is_err()); + } } diff --git a/atomic-remote/src/types.rs b/atomic-remote/src/types.rs index f9cdfcc..f933abe 100644 --- a/atomic-remote/src/types.rs +++ b/atomic-remote/src/types.rs @@ -628,6 +628,33 @@ pub enum RefUpdate { } impl RemoteViewInfo { + /// Parse a view-inventory row without silently accepting malformed data. + pub fn parse_strict(line: &str) -> Result { + let line = line.trim(); + if line.is_empty() { + return Err("row is empty".to_string()); + } + + let fields: Vec<_> = line.split('\t').map(str::trim).collect(); + if !(5..=6).contains(&fields.len()) { + return Err(format!( + "expected 5 or 6 tab-separated fields, got {}", + fields.len() + )); + } + if fields[0].is_empty() { + return Err("view name is empty".to_string()); + } + if !fields[1].eq_ignore_ascii_case("shared") && !fields[1].eq_ignore_ascii_case("draft") { + return Err(format!("unsupported view scope '{}'", fields[1])); + } + fields[3] + .parse::() + .map_err(|_| format!("invalid change count '{}'", fields[3]))?; + + Self::parse(line).ok_or_else(|| "invalid view inventory row".to_string()) + } + /// Parse one protocol line into a [`RemoteViewInfo`]. /// /// Wire format (tab-separated):