diff --git a/.lattice/implementations/cli-019.yaml b/.lattice/implementations/cli-019.yaml new file mode 100644 index 0000000..19debe9 --- /dev/null +++ b/.lattice/implementations/cli-019.yaml @@ -0,0 +1,19 @@ +id: IMP-CLI-019 +type: implementation +title: lattice diff command implementation +body: Implements branch-scoped diff via git diff --name-status. Core logic in src/diff.rs, CLI wiring in src/main.rs. Supports text, JSON, and markdown output modes. +status: active +version: 1.0.0 +created_at: 2026-03-08T18:15:58.019437+00:00 +created_by: agent:claude-2026-03-08 +requested_by: George Moon +meta: + files: + - path: src/diff.rs + - path: src/main.rs + - path: src/lib.rs + - path: tests/diff_test.rs +edges: + satisfies: + - target: REQ-CLI-019 + version: 1.0.0 diff --git a/.lattice/requirements/cli/019-branch-scoped-lattice-diff-command.yaml b/.lattice/requirements/cli/019-branch-scoped-lattice-diff-command.yaml new file mode 100644 index 0000000..88b123e --- /dev/null +++ b/.lattice/requirements/cli/019-branch-scoped-lattice-diff-command.yaml @@ -0,0 +1,20 @@ +id: REQ-CLI-019 +type: requirement +title: Branch-scoped lattice diff command +body: 'The CLI provides a ''lattice diff'' command that shows lattice nodes added, modified, or resolved since a given git ref. Defaults to merge-base with main. Supports --since for explicit ref, --md for markdown output suitable for GitHub comments, and --format json for structured output. Groups changes by type: added, modified, resolved, deleted.' +status: active +version: 1.0.0 +created_at: 2026-03-08T18:15:47.317389+00:00 +created_by: agent:claude +requested_by: George Moon +priority: P1 +category: CLI +tags: +- cli +- diff +- agent-workflow +resolution: + status: verified + resolved_at: 2026-03-08T18:16:01.109674+00:00 + resolved_by: agent:claude-2026-03-08 +edges: {} diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..e89ad42 --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,535 @@ +//! Branch-scoped lattice diff: shows nodes added, modified, or resolved since a git ref. +//! +//! Linked requirements: REQ-CLI-006 + +use crate::storage::load_node; +use crate::types::{LatticeNode, NodeType}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum DiffError { + #[error("git command failed: {0}")] + GitError(String), + #[error("failed to parse git output: {0}")] + ParseError(String), + #[error("failed to load node: {0}")] + LoadError(String), +} + +/// A single changed node in the diff. +#[derive(Debug, Clone)] +pub struct DiffEntry { + pub id: String, + pub title: String, + pub node_type: NodeType, + pub priority: Option, + pub resolution: Option, + pub change_type: ChangeType, +} + +/// Type of change detected. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ChangeType { + Added, + Modified, + Deleted, +} + +/// Result of a lattice diff operation. +#[derive(Debug, Clone)] +pub struct DiffResult { + pub base_ref: String, + pub added: Vec, + pub modified: Vec, + pub resolved: Vec, + pub deleted: Vec, +} + +impl DiffResult { + pub fn is_empty(&self) -> bool { + self.added.is_empty() + && self.modified.is_empty() + && self.resolved.is_empty() + && self.deleted.is_empty() + } + + pub fn total_count(&self) -> usize { + self.added.len() + self.modified.len() + self.resolved.len() + self.deleted.len() + } +} + +/// Compute the merge-base between HEAD and the given ref. +fn git_merge_base(base_ref: &str) -> Result { + let output = Command::new("git") + .args(["merge-base", "HEAD", base_ref]) + .output() + .map_err(|e| DiffError::GitError(format!("failed to run git merge-base: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(DiffError::GitError(format!( + "git merge-base failed: {}", + stderr.trim() + ))); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Run `git diff --name-status -- .lattice/` to find changed files. +fn git_diff_name_status( + since_ref: &str, + lattice_dir: &Path, +) -> Result, DiffError> { + let output = Command::new("git") + .args([ + "diff", + "--name-status", + since_ref, + "--", + &lattice_dir.to_string_lossy(), + ]) + .output() + .map_err(|e| DiffError::GitError(format!("failed to run git diff: {}", e)))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(DiffError::GitError(format!( + "git diff failed: {}", + stderr.trim() + ))); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut results = Vec::new(); + + for line in stdout.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Format: "A\tpath" or "M\tpath" or "D\tpath" + // Also handles rename: "R100\told_path\tnew_path" + let parts: Vec<&str> = line.split('\t').collect(); + if parts.len() < 2 { + continue; + } + + let status = parts[0]; + let path = PathBuf::from(parts[parts.len() - 1]); // Use last path (handles renames) + + // Only consider YAML files in node type directories + if !is_node_file(&path) { + continue; + } + + let change = if status.starts_with('A') || status.starts_with('R') { + "A".to_string() + } else if status.starts_with('M') { + "M".to_string() + } else if status.starts_with('D') { + "D".to_string() + } else { + continue; + }; + + results.push((change, path)); + } + + Ok(results) +} + +/// Check if a path is a lattice node YAML file (in sources/, theses/, requirements/, implementations/). +fn is_node_file(path: &Path) -> bool { + let path_str = path.to_string_lossy(); + let ext = path.extension().and_then(|e| e.to_str()); + if ext != Some("yaml") && ext != Some("yml") { + return false; + } + + // Must be inside a node type directory + for dir in &["sources/", "theses/", "requirements/", "implementations/"] { + if path_str.contains(dir) { + return true; + } + } + false +} + +/// Build a DiffEntry from a LatticeNode. +fn node_to_entry(node: &LatticeNode, change_type: ChangeType) -> DiffEntry { + let priority = node.priority.as_ref().map(|p| format!("{:?}", p)); + let resolution = node + .resolution + .as_ref() + .map(|r| format!("{:?}", r.status).to_lowercase()); + + DiffEntry { + id: node.id.clone(), + title: node.title.clone(), + node_type: node.node_type.clone(), + priority, + resolution, + change_type, + } +} + +/// Get the content of a file at a specific git ref. +fn git_show_at_ref(git_ref: &str, path: &Path) -> Result, DiffError> { + let output = Command::new("git") + .args(["show", &format!("{}:{}", git_ref, path.to_string_lossy())]) + .output() + .map_err(|e| DiffError::GitError(format!("failed to run git show: {}", e)))?; + + if !output.status.success() { + // File doesn't exist at that ref + return Ok(None); + } + + Ok(Some(String::from_utf8_lossy(&output.stdout).to_string())) +} + +/// Parse a YAML string into a LatticeNode. +fn parse_node_yaml(yaml: &str) -> Result { + serde_yaml::from_str(yaml) + .map_err(|e| DiffError::ParseError(format!("YAML parse error: {}", e))) +} + +/// Check if a modified node was resolved (wasn't resolved before, is now). +fn was_resolved(old_yaml: &str, new_node: &LatticeNode) -> bool { + if new_node.resolution.is_none() { + return false; + } + // Check if old version had no resolution + if let Ok(old_node) = parse_node_yaml(old_yaml) { + return old_node.resolution.is_none(); + } + false +} + +/// Compute lattice diff since a given git ref. +/// +/// If `since` is None, defaults to merge-base with `main`. +pub fn lattice_diff(lattice_root: &Path, since: Option<&str>) -> Result { + let lattice_dir = lattice_root.join(".lattice"); + + // Determine the base ref + let base_ref = match since { + Some(r) => r.to_string(), + None => git_merge_base("main") + .or_else(|_| git_merge_base("master")) + .map_err(|_| { + DiffError::GitError("could not find merge-base with main or master".to_string()) + })?, + }; + + let changes = git_diff_name_status(&base_ref, &lattice_dir)?; + + let mut added = Vec::new(); + let mut modified = Vec::new(); + let mut resolved = Vec::new(); + let mut deleted = Vec::new(); + + for (status, path) in &changes { + match status.as_str() { + "A" => { + // Added: load current file + if let Ok(node) = load_node(path) { + // Check if the added node is already resolved + if node.resolution.is_some() { + resolved.push(node_to_entry(&node, ChangeType::Added)); + } else { + added.push(node_to_entry(&node, ChangeType::Added)); + } + } + } + "M" => { + // Modified: load current file, check if newly resolved + if let Ok(node) = load_node(path) { + // Check if the node was resolved in this change + if let Ok(Some(old_yaml)) = git_show_at_ref(&base_ref, path) + && was_resolved(&old_yaml, &node) + { + resolved.push(node_to_entry(&node, ChangeType::Modified)); + continue; + } + modified.push(node_to_entry(&node, ChangeType::Modified)); + } + } + "D" => { + // Deleted: try to load from old ref + if let Ok(Some(old_yaml)) = git_show_at_ref(&base_ref, path) + && let Ok(node) = parse_node_yaml(&old_yaml) + { + deleted.push(node_to_entry(&node, ChangeType::Deleted)); + } + } + _ => {} + } + } + + // Sort each category by ID for deterministic output + added.sort_by(|a, b| a.id.cmp(&b.id)); + modified.sort_by(|a, b| a.id.cmp(&b.id)); + resolved.sort_by(|a, b| a.id.cmp(&b.id)); + deleted.sort_by(|a, b| a.id.cmp(&b.id)); + + Ok(DiffResult { + base_ref, + added, + modified, + resolved, + deleted, + }) +} + +/// Format a DiffEntry as a display line. +fn format_entry(entry: &DiffEntry) -> String { + let mut parts = vec![format!("{}: {}", entry.id, entry.title)]; + + if let Some(ref p) = entry.priority { + parts.push(format!("({})", p)); + } + + if let Some(ref r) = entry.resolution { + parts.push(format!("({})", r)); + } + + parts.join(" ") +} + +/// Format the diff result as markdown (for --md flag). +pub fn format_diff_markdown(result: &DiffResult) -> String { + let mut lines = Vec::new(); + lines.push("## Lattice Changes".to_string()); + lines.push(String::new()); + + if result.is_empty() { + lines.push("No lattice changes detected.".to_string()); + return lines.join("\n"); + } + + if !result.added.is_empty() { + lines.push("### Added".to_string()); + for entry in &result.added { + lines.push(format!("- {}", format_entry(entry))); + } + lines.push(String::new()); + } + + if !result.modified.is_empty() { + lines.push("### Modified".to_string()); + for entry in &result.modified { + lines.push(format!("- {}", format_entry(entry))); + } + lines.push(String::new()); + } + + if !result.resolved.is_empty() { + lines.push("### Resolved".to_string()); + for entry in &result.resolved { + lines.push(format!("- {}", format_entry(entry))); + } + lines.push(String::new()); + } + + if !result.deleted.is_empty() { + lines.push("### Deleted".to_string()); + for entry in &result.deleted { + lines.push(format!("- {}", format_entry(entry))); + } + lines.push(String::new()); + } + + lines.join("\n") +} + +/// Format a single entry as a text line with color hints. +pub fn format_entry_text(entry: &DiffEntry) -> String { + format_entry(entry) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_node_file() { + assert!(is_node_file(Path::new( + ".lattice/requirements/cli/001-init.yaml" + ))); + assert!(is_node_file(Path::new(".lattice/sources/src-example.yaml"))); + assert!(is_node_file(Path::new( + ".lattice/theses/thx-something.yaml" + ))); + assert!(is_node_file(Path::new( + ".lattice/implementations/imp-test.yaml" + ))); + assert!(!is_node_file(Path::new(".lattice/config.yaml"))); + assert!(!is_node_file(Path::new( + ".lattice/requirements/cli/001.txt" + ))); + } + + #[test] + fn test_diff_result_is_empty() { + let result = DiffResult { + base_ref: "abc123".to_string(), + added: vec![], + modified: vec![], + resolved: vec![], + deleted: vec![], + }; + assert!(result.is_empty()); + assert_eq!(result.total_count(), 0); + } + + #[test] + fn test_diff_result_not_empty() { + let entry = DiffEntry { + id: "REQ-TEST-001".to_string(), + title: "Test req".to_string(), + node_type: NodeType::Requirement, + priority: Some("P1".to_string()), + resolution: None, + change_type: ChangeType::Added, + }; + let result = DiffResult { + base_ref: "abc123".to_string(), + added: vec![entry], + modified: vec![], + resolved: vec![], + deleted: vec![], + }; + assert!(!result.is_empty()); + assert_eq!(result.total_count(), 1); + } + + #[test] + fn test_format_entry_with_priority() { + let entry = DiffEntry { + id: "REQ-API-007".to_string(), + title: "Rate limiting".to_string(), + node_type: NodeType::Requirement, + priority: Some("P1".to_string()), + resolution: None, + change_type: ChangeType::Added, + }; + let formatted = format_entry(&entry); + assert_eq!(formatted, "REQ-API-007: Rate limiting (P1)"); + } + + #[test] + fn test_format_entry_with_resolution() { + let entry = DiffEntry { + id: "REQ-API-007".to_string(), + title: "Rate limiting".to_string(), + node_type: NodeType::Requirement, + priority: None, + resolution: Some("verified".to_string()), + change_type: ChangeType::Modified, + }; + let formatted = format_entry(&entry); + assert_eq!(formatted, "REQ-API-007: Rate limiting (verified)"); + } + + #[test] + fn test_format_entry_plain() { + let entry = DiffEntry { + id: "SRC-TEST".to_string(), + title: "Test source".to_string(), + node_type: NodeType::Source, + priority: None, + resolution: None, + change_type: ChangeType::Added, + }; + let formatted = format_entry(&entry); + assert_eq!(formatted, "SRC-TEST: Test source"); + } + + #[test] + fn test_format_diff_markdown_empty() { + let result = DiffResult { + base_ref: "abc123".to_string(), + added: vec![], + modified: vec![], + resolved: vec![], + deleted: vec![], + }; + let md = format_diff_markdown(&result); + assert!(md.contains("## Lattice Changes")); + assert!(md.contains("No lattice changes detected.")); + } + + #[test] + fn test_format_diff_markdown_with_entries() { + let result = DiffResult { + base_ref: "abc123".to_string(), + added: vec![DiffEntry { + id: "REQ-NEW-001".to_string(), + title: "New requirement".to_string(), + node_type: NodeType::Requirement, + priority: Some("P1".to_string()), + resolution: None, + change_type: ChangeType::Added, + }], + modified: vec![DiffEntry { + id: "THX-OPS".to_string(), + title: "Updated thesis".to_string(), + node_type: NodeType::Thesis, + priority: None, + resolution: None, + change_type: ChangeType::Modified, + }], + resolved: vec![DiffEntry { + id: "REQ-OLD-001".to_string(), + title: "Resolved req".to_string(), + node_type: NodeType::Requirement, + priority: None, + resolution: Some("verified".to_string()), + change_type: ChangeType::Modified, + }], + deleted: vec![], + }; + let md = format_diff_markdown(&result); + assert!(md.contains("### Added")); + assert!(md.contains("- REQ-NEW-001: New requirement (P1)")); + assert!(md.contains("### Modified")); + assert!(md.contains("- THX-OPS: Updated thesis")); + assert!(md.contains("### Resolved")); + assert!(md.contains("- REQ-OLD-001: Resolved req (verified)")); + assert!(!md.contains("### Deleted")); + } + + #[test] + fn test_change_type_equality() { + assert_eq!(ChangeType::Added, ChangeType::Added); + assert_ne!(ChangeType::Added, ChangeType::Modified); + assert_ne!(ChangeType::Modified, ChangeType::Deleted); + } + + #[test] + fn test_format_diff_markdown_deleted() { + let result = DiffResult { + base_ref: "abc123".to_string(), + added: vec![], + modified: vec![], + resolved: vec![], + deleted: vec![DiffEntry { + id: "SRC-OLD".to_string(), + title: "Removed source".to_string(), + node_type: NodeType::Source, + priority: None, + resolution: None, + change_type: ChangeType::Deleted, + }], + }; + let md = format_diff_markdown(&result); + assert!(md.contains("### Deleted")); + assert!(md.contains("- SRC-OLD: Removed source")); + assert!(!md.contains("### Added")); + } +} diff --git a/src/lib.rs b/src/lib.rs index ed06601..ffc5287 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ //! interconnected knowledge nodes (sources, theses, requirements, implementations) //! with version-bound edges and drift detection. +pub mod diff; pub mod export; pub mod graph; pub mod html_export; @@ -14,6 +15,10 @@ pub mod storage; pub mod types; pub mod update; +pub use diff::{ + ChangeType, DiffEntry, DiffError, DiffResult, format_diff_markdown, format_entry_text, + lattice_diff, +}; pub use export::{Audience, ExportOptions, LatticeData, export_narrative}; pub use graph::{ DriftReport, DriftSeverity, Plan, PlannedItem, build_node_index, find_drift, generate_plan, diff --git a/src/main.rs b/src/main.rs index 0ff9d34..3882cc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,13 +6,14 @@ use clap::{Parser, Subcommand}; use colored::Colorize; use lattice::{ AddEdgeOptions, AddImplementationOptions, AddRequirementOptions, AddSourceOptions, - AddThesisOptions, Audience, DriftSeverity, EditNodeOptions, ExportOptions, GapType, + AddThesisOptions, Audience, DiffEntry, DriftSeverity, EditNodeOptions, ExportOptions, GapType, HtmlExportOptions, LatticeData, LintSeverity, Plan, Priority, RefineOptions, Resolution, ResolveOptions, SearchEngine, SearchParams, Status, VerifyOptions, add_edge, add_implementation, add_requirement, add_source, add_thesis, build_node_index, edit_node, - export_html, export_narrative, find_drift, find_lattice_root, fix_issues, generate_plan, - get_github_pages_url, init_lattice, lint_lattice, load_config, load_nodes_by_type, - refine_requirement, resolve_node, split_csv, verify_implementation, + export_html, export_narrative, find_drift, find_lattice_root, fix_issues, format_diff_markdown, + format_entry_text, generate_plan, get_github_pages_url, init_lattice, lattice_diff, + lint_lattice, load_config, load_nodes_by_type, refine_requirement, resolve_node, split_csv, + verify_implementation, }; use serde_json::json; use std::env; @@ -372,6 +373,21 @@ enum Commands { mcp: bool, }, + /// Show lattice nodes added, modified, or resolved since a git ref + Diff { + /// Git ref to compare against (default: merge-base with main) + #[arg(long)] + since: Option, + + /// Output as markdown (for GitHub comments) + #[arg(long)] + md: bool, + + /// Output format (text, json) + #[arg(short, long, default_value = "text")] + format: String, + }, + /// Show available commands (use --json for machine-readable catalog) Help { /// Output as structured JSON for agent consumption @@ -1196,6 +1212,22 @@ fn build_command_catalog() -> serde_json::Value { "lattice summary --format json" ] }, + { + "name": "diff", + "description": "Show lattice nodes added, modified, or resolved since a git ref", + "parameters": [ + param("--since", "string", false, "Git ref to compare against (default: merge-base with main)"), + param("--md", "bool", false, "Output as markdown for GitHub comments"), + param_s("--format", "-f", "string", false, "Output format: text, json (default: text)") + ], + "examples": [ + "lattice diff", + "lattice diff --since main", + "lattice diff --since abc123f", + "lattice diff --since main --md", + "lattice diff --format json" + ] + }, { "name": "plan", "description": "Plan implementation order for requirements based on dependency graph", @@ -1283,6 +1315,7 @@ fn command_to_name(cmd: &Commands) -> &'static str { Commands::Mcp => "mcp", Commands::Update { .. } => "update", Commands::Prompt { .. } => "prompt", + Commands::Diff { .. } => "diff", Commands::Help { .. } => "help", } } @@ -2930,6 +2963,101 @@ fn main() { ); } + Commands::Diff { since, md, format } => { + let root = get_lattice_root(); + + match lattice_diff(&root, since.as_deref()) { + Ok(result) => { + if md { + println!("{}", format_diff_markdown(&result)); + } else if is_json(&format) { + let to_json_entries = |entries: &[DiffEntry]| -> Vec { + entries + .iter() + .map(|e| { + let mut obj = json!({ + "id": e.id, + "title": e.title, + "node_type": format!("{:?}", e.node_type).to_lowercase(), + }); + if let Some(ref p) = e.priority { + obj["priority"] = json!(p); + } + if let Some(ref r) = e.resolution { + obj["resolution"] = json!(r); + } + obj + }) + .collect() + }; + + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "base_ref": result.base_ref, + "has_changes": !result.is_empty(), + "total_changes": result.total_count(), + "added": to_json_entries(&result.added), + "modified": to_json_entries(&result.modified), + "resolved": to_json_entries(&result.resolved), + "deleted": to_json_entries(&result.deleted), + })) + .unwrap() + ); + } else { + // Text output + if result.is_empty() { + println!("{}", "No lattice changes detected.".green()); + return; + } + + println!( + "{}", + format!( + "Lattice changes since {} ({} total):\n", + &result.base_ref[..std::cmp::min(8, result.base_ref.len())], + result.total_count() + ) + .bold() + ); + + if !result.added.is_empty() { + println!("{}", "Added:".green().bold()); + for entry in &result.added { + println!(" {} {}", "+".green(), format_entry_text(entry)); + } + println!(); + } + + if !result.modified.is_empty() { + println!("{}", "Modified:".yellow().bold()); + for entry in &result.modified { + println!(" {} {}", "~".yellow(), format_entry_text(entry)); + } + println!(); + } + + if !result.resolved.is_empty() { + println!("{}", "Resolved:".cyan().bold()); + for entry in &result.resolved { + println!(" {} {}", "✓".cyan(), format_entry_text(entry)); + } + println!(); + } + + if !result.deleted.is_empty() { + println!("{}", "Deleted:".red().bold()); + for entry in &result.deleted { + println!(" {} {}", "-".red(), format_entry_text(entry)); + } + println!(); + } + } + } + Err(e) => emit_error(&format, "diff_error", &e.to_string()), + } + } + Commands::Help { json } => { if json { println!( @@ -3068,6 +3196,7 @@ mod tests { "edit", "verify", "refine", + "diff", "drift", "lint", "summary", diff --git a/tests/diff_test.rs b/tests/diff_test.rs new file mode 100644 index 0000000..32fd1c9 --- /dev/null +++ b/tests/diff_test.rs @@ -0,0 +1,258 @@ +//! Integration tests for `lattice diff` command. + +use assert_cmd::cargo::cargo_bin_cmd; + +/// Helper: check if git history has at least `n` commits. +fn has_git_history(n: usize) -> bool { + let output = std::process::Command::new("git") + .args(["log", "--oneline", &format!("-{}", n)]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .ok(); + match output { + Some(o) => { + let lines = String::from_utf8_lossy(&o.stdout).lines().count(); + lines >= n + } + None => false, + } +} + +/// Helper: get the earliest reachable commit (works with shallow clones). +fn earliest_reachable_commit() -> Option { + let output = std::process::Command::new("git") + .args(["log", "--reverse", "--format=%H", "--max-count=1"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .ok()?; + let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if hash.is_empty() { None } else { Some(hash) } +} + +/// Test that `lattice diff` runs without error on HEAD (no changes expected). +#[test] +fn test_diff_default_no_crash() { + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", "HEAD"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!( + output.status.success(), + "lattice diff --since HEAD should succeed. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("No lattice changes detected."), + "Expected no changes when comparing HEAD to HEAD, got: {}", + stdout + ); +} + +/// Test that `lattice diff --format json` returns valid JSON with expected structure. +#[test] +fn test_diff_json_output_structure() { + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", "HEAD", "--format", "json"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!(output.status.success()); + + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("Should be valid JSON"); + + // Verify expected fields + assert!(json.get("base_ref").is_some(), "Missing base_ref field"); + assert!( + json.get("has_changes").is_some(), + "Missing has_changes field" + ); + assert!( + json.get("total_changes").is_some(), + "Missing total_changes field" + ); + assert!(json.get("added").is_some(), "Missing added field"); + assert!(json.get("modified").is_some(), "Missing modified field"); + assert!(json.get("resolved").is_some(), "Missing resolved field"); + assert!(json.get("deleted").is_some(), "Missing deleted field"); + + // HEAD vs HEAD should have no changes + assert_eq!(json["has_changes"], false); + assert_eq!(json["total_changes"], 0); + assert!(json["added"].as_array().unwrap().is_empty()); +} + +/// Test that `lattice diff --md` produces markdown output. +#[test] +fn test_diff_markdown_output() { + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", "HEAD", "--md"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("## Lattice Changes"), + "Markdown output should contain heading" + ); +} + +/// Test diff with the earliest reachable commit shows additions. +/// Skips gracefully in shallow clones where the earliest commit already has .lattice/ files. +#[test] +fn test_diff_since_earliest_commit() { + let Some(earliest) = earliest_reachable_commit() else { + return; // No git history available + }; + + // Check if the earliest commit already contains .lattice/ files + // (in shallow clones, git diff from the grafted root may show no changes) + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", &earliest, "--format", "json"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!( + output.status.success(), + "diff since earliest commit should succeed. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("Should be valid JSON"); + + // Verify the structure is correct regardless of whether changes were detected + assert!(json.get("base_ref").is_some()); + assert!(json.get("has_changes").is_some()); + assert!(json.get("added").is_some()); + + // If changes were detected, verify entry structure + let added = json["added"].as_array().unwrap(); + if !added.is_empty() { + let first = &added[0]; + assert!(first.get("id").is_some(), "Entry should have id"); + assert!(first.get("title").is_some(), "Entry should have title"); + assert!( + first.get("node_type").is_some(), + "Entry should have node_type" + ); + } +} + +/// Test that --since flag with HEAD~1 works when history is available. +#[test] +fn test_diff_since_parent_commit() { + if !has_git_history(2) { + // Shallow clone — skip gracefully + return; + } + + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", "HEAD~1", "--format", "json"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!( + output.status.success(), + "lattice diff --since HEAD~1 should succeed. stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("Should be valid JSON"); + assert!(json.get("base_ref").is_some()); +} + +/// Test that `lattice diff --md` with changes produces proper markdown sections. +#[test] +fn test_diff_markdown_sections_with_changes() { + if !has_git_history(2) { + return; + } + + let Some(earliest) = earliest_reachable_commit() else { + return; + }; + + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", &earliest, "--md"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("## Lattice Changes")); + + // If there are changes, verify markdown structure + if stdout.contains("### Added") { + assert!( + stdout.contains("REQ-") || stdout.contains("SRC-") || stdout.contains("THX-"), + "Added section should contain node IDs" + ); + } +} + +/// Test that invalid ref produces error. +#[test] +fn test_diff_invalid_ref_errors() { + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", "nonexistent-ref-abc123"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "Invalid ref should cause non-zero exit" + ); +} + +/// Test text output format. +#[test] +fn test_diff_text_output_format() { + if !has_git_history(2) { + return; + } + + let Some(earliest) = earliest_reachable_commit() else { + return; + }; + + let mut cmd = cargo_bin_cmd!("lattice"); + let output = cmd + .args(["diff", "--since", &earliest]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .unwrap(); + + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + // Either we have changes (with summary header) or no changes + assert!( + stdout.contains("Lattice changes since") + || stdout.contains("Added") + || stdout.contains("No lattice changes detected."), + "Text output should show changes or no-changes message, got: {}", + stdout + ); +}