Skip to main content

mars_agents/cli/
init.rs

1//! `mars init [TARGET] [--link DIR...]` — scaffold a mars project.
2//!
3//! Creates `<project-root>/mars.toml` and `<project-root>/TARGET` (default: `.agents`).
4//! Use `--root` to select an explicit project root.
5//!
6//! Idempotent: re-running is a no-op for initialization but still processes
7//! `--link` flags.
8
9use std::path::Path;
10
11use crate::error::{ConfigError, MarsError};
12
13use super::output;
14
15/// Arguments for `mars init`.
16#[derive(Debug, clap::Args)]
17pub struct InitArgs {
18    /// Directory name to create for managed output (default: .agents).
19    pub target: Option<String>,
20
21    /// Directories to link after initialization. Repeatable.
22    #[arg(long, value_name = "DIR")]
23    pub link: Vec<String>,
24}
25
26/// Validate that a target is a simple directory name, not a path.
27fn validate_target(target: &str) -> Result<(), MarsError> {
28    if target.contains('/') || target.contains('\\') {
29        return Err(MarsError::Config(ConfigError::Invalid {
30            message: format!(
31                "`{target}` looks like a path — TARGET should be a directory name \
32                 like `.agents` or `.claude`. Use `--root` to specify project root."
33            ),
34        }));
35    }
36    if target == "." || target == ".." || target.is_empty() {
37        return Err(MarsError::Config(ConfigError::Invalid {
38            message: format!(
39                "`{target}` is not a valid target name — use a directory name like `.agents` or `.claude`."
40            ),
41        }));
42    }
43    Ok(())
44}
45
46fn ensure_consumer_config(project_root: &Path) -> Result<bool, MarsError> {
47    let config_path = project_root.join("mars.toml");
48    if config_path.exists() {
49        return Ok(true);
50    }
51
52    crate::fs::atomic_write(&config_path, b"[dependencies]\n")?;
53    Ok(false)
54}
55
56/// Run `mars init`.
57pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
58    // 1. Determine project root
59    let project_root = explicit_root.map(Path::to_path_buf).unwrap_or_else(|| {
60        super::default_project_root().unwrap_or_else(|_| std::env::current_dir().unwrap())
61    });
62
63    // 2. Determine target: argument → existing settings.managed_root → .agents
64    let target = if let Some(t) = args.target.as_deref() {
65        t.to_string()
66    } else {
67        // Check existing config for persisted managed_root
68        match crate::config::load(&project_root) {
69            Ok(config) => config
70                .settings
71                .managed_root
72                .unwrap_or_else(|| ".agents".into()),
73            Err(_) => ".agents".into(),
74        }
75    };
76
77    validate_target(&target)?;
78    let managed_root = project_root.join(&target);
79
80    // 3. Ensure project config + managed structure
81    std::fs::create_dir_all(&managed_root)?;
82    std::fs::create_dir_all(project_root.join(".mars"))?;
83
84    let already_initialized = ensure_consumer_config(&project_root)?;
85
86    // 4. Persist settings.managed_root.
87    persist_managed_root(&project_root, &target)?;
88
89    if !json {
90        if already_initialized {
91            output::print_info(&format!("{} already initialized", project_root.display()));
92        } else {
93            output::print_success(&format!(
94                "initialized {} with mars.toml",
95                project_root.display()
96            ));
97        }
98    }
99
100    // 5. Process --link flags
101    if !args.link.is_empty() {
102        let ctx = super::MarsContext::from_roots(project_root.clone(), managed_root.clone())?;
103        for link_target in &args.link {
104            let link_args = super::link::LinkArgs {
105                target: link_target.clone(),
106                unlink: false,
107            };
108            super::link::run(&link_args, &ctx, json)?;
109        }
110    }
111
112    if json {
113        output::print_json(&serde_json::json!({
114            "ok": true,
115            "project_root": project_root.to_string_lossy(),
116            "managed_root": managed_root.to_string_lossy(),
117            "already_initialized": already_initialized,
118            "links": args.link,
119        }));
120    }
121
122    Ok(0)
123}
124
125/// Persist managed_root in mars.toml [settings].
126fn persist_managed_root(project_root: &Path, target: &str) -> Result<(), MarsError> {
127    match crate::config::load(project_root) {
128        Ok(mut config) => {
129            config.settings.managed_root = if target == ".agents" {
130                None
131            } else {
132                Some(target.to_string())
133            };
134            crate::config::save(project_root, &config)?;
135        }
136        Err(MarsError::Config(ConfigError::NotFound { .. })) => {
137            // Config will be created by ensure_consumer_config — skip
138        }
139        Err(e) => return Err(e),
140    }
141    Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use tempfile::TempDir;
148
149    #[test]
150    fn validate_target_accepts_simple_names() {
151        assert!(validate_target(".agents").is_ok());
152        assert!(validate_target(".claude").is_ok());
153        assert!(validate_target("my-agents").is_ok());
154    }
155
156    #[test]
157    fn validate_target_rejects_paths() {
158        assert!(validate_target("./foo").is_err());
159        assert!(validate_target("foo/bar").is_err());
160        assert!(validate_target("/absolute/path").is_err());
161    }
162
163    #[test]
164    fn validate_target_rejects_dots() {
165        assert!(validate_target(".").is_err());
166        assert!(validate_target("..").is_err());
167    }
168
169    #[test]
170    fn validate_target_rejects_empty() {
171        assert!(validate_target("").is_err());
172    }
173
174    #[test]
175    fn ensure_consumer_config_creates_root_mars_toml() {
176        let dir = TempDir::new().unwrap();
177
178        let already = ensure_consumer_config(dir.path()).unwrap();
179        assert!(!already);
180
181        let content = std::fs::read_to_string(dir.path().join("mars.toml")).unwrap();
182        assert!(content.contains("[dependencies]"));
183    }
184
185    #[test]
186    fn ensure_consumer_config_accepts_existing_mars_toml() {
187        let dir = TempDir::new().unwrap();
188        std::fs::write(
189            dir.path().join("mars.toml"),
190            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
191        )
192        .unwrap();
193
194        let already = ensure_consumer_config(dir.path()).unwrap();
195        assert!(already);
196    }
197}