diff --git a/rust/crates/rusty-claude-cli/src/init.rs b/rust/crates/rusty-claude-cli/src/init.rs index b8c1c6e..2bb83f9 100644 --- a/rust/crates/rusty-claude-cli/src/init.rs +++ b/rust/crates/rusty-claude-cli/src/init.rs @@ -27,6 +27,18 @@ impl InitStatus { Self::Skipped => "skipped (already exists)", } } + + /// Machine-stable identifier for structured output (#142). + /// Unlike `label()`, this never changes wording: claws can switch on + /// these values without brittle substring matching. + #[must_use] + pub(crate) fn json_tag(self) -> &'static str { + match self { + Self::Created => "created", + Self::Updated => "updated", + Self::Skipped => "skipped", + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -58,6 +70,36 @@ impl InitReport { lines.push(" Next step Review and tailor the generated guidance".to_string()); lines.join("\n") } + + /// Summary constant that claws can embed in JSON output without having + /// to read it out of the human-formatted `message` string (#142). + pub(crate) const NEXT_STEP: &'static str = "Review and tailor the generated guidance"; + + /// Artifact names that ended in the given status. Used to build the + /// structured `created[]`/`updated[]`/`skipped[]` arrays for #142. + #[must_use] + pub(crate) fn artifacts_with_status(&self, status: InitStatus) -> Vec { + self.artifacts + .iter() + .filter(|artifact| artifact.status == status) + .map(|artifact| artifact.name.to_string()) + .collect() + } + + /// Structured artifact list for JSON output (#142). Each entry carries + /// `name` and machine-stable `status` tag. + #[must_use] + pub(crate) fn artifact_json_entries(&self) -> Vec { + self.artifacts + .iter() + .map(|artifact| { + serde_json::json!({ + "name": artifact.name, + "status": artifact.status.json_tag(), + }) + }) + .collect() + } } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -333,7 +375,7 @@ fn framework_notes(detection: &RepoDetection) -> Vec { #[cfg(test)] mod tests { - use super::{initialize_repo, render_init_claude_md}; + use super::{initialize_repo, render_init_claude_md, InitStatus}; use std::fs; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -413,6 +455,63 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn artifacts_with_status_partitions_fresh_and_idempotent_runs() { + // #142: the structured JSON output needs to be able to partition + // artifacts into created/updated/skipped without substring matching + // the human-formatted `message` string. + let root = temp_dir(); + fs::create_dir_all(&root).expect("create root"); + + let fresh = initialize_repo(&root).expect("fresh init should succeed"); + let created_names = fresh.artifacts_with_status(InitStatus::Created); + assert_eq!( + created_names, + vec![ + ".claw/".to_string(), + ".claw.json".to_string(), + ".gitignore".to_string(), + "CLAUDE.md".to_string(), + ], + "fresh init should place all four artifacts in created[]" + ); + assert!( + fresh.artifacts_with_status(InitStatus::Skipped).is_empty(), + "fresh init should have no skipped artifacts" + ); + + let second = initialize_repo(&root).expect("second init should succeed"); + let skipped_names = second.artifacts_with_status(InitStatus::Skipped); + assert_eq!( + skipped_names, + vec![ + ".claw/".to_string(), + ".claw.json".to_string(), + ".gitignore".to_string(), + "CLAUDE.md".to_string(), + ], + "idempotent init should place all four artifacts in skipped[]" + ); + assert!( + second.artifacts_with_status(InitStatus::Created).is_empty(), + "idempotent init should have no created artifacts" + ); + + // artifact_json_entries() uses the machine-stable `json_tag()` which + // never changes wording (unlike `label()` which says "skipped (already exists)"). + let entries = second.artifact_json_entries(); + assert_eq!(entries.len(), 4); + for entry in &entries { + let status = entry.get("status").and_then(|v| v.as_str()).unwrap(); + assert_eq!( + status, "skipped", + "machine status tag should be the bare word 'skipped', not label()'s 'skipped (already exists)'" + ); + } + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn render_init_template_mentions_detected_python_and_nextjs_markers() { let root = temp_dir(); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 18f428b..178958e 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2948,11 +2948,15 @@ fn run_resume_command( json: Some(render_memory_json()?), }), SlashCommand::Init => { - let message = init_claude_md()?; + // #142: run the init once, then render both text + structured JSON + // from the same InitReport so both surfaces stay in sync. + let cwd = env::current_dir()?; + let report = crate::init::initialize_repo(&cwd)?; + let message = report.render(); Ok(ResumeCommandOutcome { session: session.clone(), message: Some(message.clone()), - json: Some(init_json_value(&message)), + json: Some(init_json_value(&report, &message)), }) } SlashCommand::Diff => { @@ -5666,20 +5670,31 @@ fn init_claude_md() -> Result> { } fn run_init(output_format: CliOutputFormat) -> Result<(), Box> { - let message = init_claude_md()?; + let cwd = env::current_dir()?; + let report = initialize_repo(&cwd)?; + let message = report.render(); match output_format { CliOutputFormat::Text => println!("{message}"), CliOutputFormat::Json => println!( "{}", - serde_json::to_string_pretty(&init_json_value(&message))? + serde_json::to_string_pretty(&init_json_value(&report, &message))? ), } Ok(()) } -fn init_json_value(message: &str) -> serde_json::Value { +/// #142: emit first-class structured fields alongside the legacy `message` +/// string so claws can detect per-artifact state without substring matching. +fn init_json_value(report: &crate::init::InitReport, message: &str) -> serde_json::Value { + use crate::init::InitStatus; json!({ "kind": "init", + "project_path": report.project_root.display().to_string(), + "created": report.artifacts_with_status(InitStatus::Created), + "updated": report.artifacts_with_status(InitStatus::Updated), + "skipped": report.artifacts_with_status(InitStatus::Skipped), + "artifacts": report.artifact_json_entries(), + "next_step": crate::init::InitReport::NEXT_STEP, "message": message, }) }