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>/.mars`.
4//! If TARGET is provided, also creates `<project-root>/TARGET` as a managed output dir.
5//! Use `--root` to select an explicit project root.
6//!
7//! Init does NOT walk up — it creates a project at cwd or the `--root` target.
8//! Idempotent: re-running is a no-op for initialization but still processes
9//! `--link` flags.
10
11use std::path::{Path, PathBuf};
12
13use crate::error::{ConfigError, MarsError};
14
15use super::output;
16
17/// Arguments for `mars init`.
18#[derive(Debug, clap::Args)]
19pub struct InitArgs {
20    /// Optional directory name to create for managed output.
21    pub target: Option<String>,
22
23    /// Directories to link after initialization. Repeatable.
24    #[arg(long, value_name = "DIR")]
25    pub link: Vec<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub(super) struct InitializedProject {
30    pub project_root: PathBuf,
31    pub managed_root: Option<PathBuf>,
32    pub already_initialized: bool,
33}
34
35fn ensure_consumer_config(project_root: &Path) -> Result<bool, MarsError> {
36    let config_path = project_root.join("mars.toml");
37    if config_path.exists() {
38        return Ok(true);
39    }
40
41    crate::fs::atomic_write(&config_path, b"[dependencies]\n")?;
42    Ok(false)
43}
44
45pub(super) fn initialize_project(
46    explicit_root: Option<&Path>,
47    target_override: Option<&str>,
48) -> Result<InitializedProject, MarsError> {
49    let project_root = explicit_root
50        .map(Path::to_path_buf)
51        .unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory"));
52
53    std::fs::create_dir_all(project_root.join(".mars"))?;
54
55    let already_initialized = ensure_consumer_config(&project_root)?;
56
57    let managed_root = if let Some(target) = explicit_init_target(&project_root, target_override)? {
58        super::target::validate_target(&target)?;
59        let managed_root = project_root.join(&target);
60        std::fs::create_dir_all(&managed_root)?;
61        persist_managed_root(&project_root, Some(&target))?;
62        Some(managed_root)
63    } else {
64        persist_managed_root(&project_root, None)?;
65        None
66    };
67
68    Ok(InitializedProject {
69        project_root,
70        managed_root,
71        already_initialized,
72    })
73}
74
75/// Run `mars init`.
76///
77/// Init creates a project at cwd or `--root` target. It does NOT walk up.
78pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
79    let initialized = initialize_project(explicit_root, args.target.as_deref())?;
80    let project_root = initialized.project_root;
81    let managed_root = initialized.managed_root;
82    let already_initialized = initialized.already_initialized;
83
84    if !json {
85        if already_initialized {
86            output::print_info(&format!("{} already initialized", project_root.display()));
87        } else {
88            output::print_success(&format!(
89                "initialized {} with mars.toml",
90                project_root.display()
91            ));
92        }
93    }
94
95    // 5. Process --link flags
96    if !args.link.is_empty() {
97        let context_managed_root = managed_root
98            .clone()
99            .unwrap_or_else(|| project_root.join(".mars"));
100        let ctx = super::MarsContext::from_roots(project_root.clone(), context_managed_root)?;
101        for link_target in &args.link {
102            let link_args = super::link::LinkArgs {
103                target: link_target.clone(),
104            };
105            super::link::run(&link_args, &ctx, json)?;
106        }
107    }
108
109    if json {
110        output::print_json(&serde_json::json!({
111            "ok": true,
112            "project_root": project_root.to_string_lossy(),
113            "managed_root": managed_root.as_ref().map(|path| path.to_string_lossy().to_string()),
114            "already_initialized": already_initialized,
115            "links": args.link,
116        }));
117    }
118
119    Ok(0)
120}
121
122fn explicit_init_target(
123    project_root: &Path,
124    target_override: Option<&str>,
125) -> Result<Option<String>, MarsError> {
126    if let Some(target) = target_override {
127        return Ok(Some(target.to_string()));
128    }
129
130    match crate::config::load(project_root) {
131        Ok(config) => Ok(config.settings.managed_root),
132        Err(MarsError::Config(ConfigError::NotFound { .. })) => Ok(None),
133        Err(e) => Err(e),
134    }
135}
136
137/// Persist managed_root in mars.toml [settings].
138fn persist_managed_root(project_root: &Path, target: Option<&str>) -> Result<(), MarsError> {
139    match crate::config::load(project_root) {
140        Ok(mut config) => {
141            config.settings.managed_root = target.map(str::to_string);
142            crate::config::save(project_root, &config)?;
143        }
144        Err(MarsError::Config(ConfigError::NotFound { .. })) => {
145            // Config will be created by ensure_consumer_config — skip
146        }
147        Err(e) => return Err(e),
148    }
149    Ok(())
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::cli::target::validate_target;
156    use tempfile::TempDir;
157
158    #[test]
159    fn validate_target_accepts_simple_names() {
160        assert!(validate_target(".agents").is_ok());
161        assert!(validate_target(".claude").is_ok());
162        assert!(validate_target("my-agents").is_ok());
163    }
164
165    #[test]
166    fn validate_target_rejects_paths() {
167        assert!(validate_target("./foo").is_err());
168        assert!(validate_target("foo/bar").is_err());
169        assert!(validate_target("/absolute/path").is_err());
170    }
171
172    #[test]
173    fn validate_target_rejects_dots() {
174        assert!(validate_target(".").is_err());
175        assert!(validate_target("..").is_err());
176    }
177
178    #[test]
179    fn validate_target_rejects_empty() {
180        assert!(validate_target("").is_err());
181    }
182
183    #[test]
184    fn ensure_consumer_config_creates_root_mars_toml() {
185        let dir = TempDir::new().unwrap();
186
187        let already = ensure_consumer_config(dir.path()).unwrap();
188        assert!(!already);
189
190        let content = std::fs::read_to_string(dir.path().join("mars.toml")).unwrap();
191        assert!(content.contains("[dependencies]"));
192    }
193
194    #[test]
195    fn ensure_consumer_config_accepts_existing_mars_toml() {
196        let dir = TempDir::new().unwrap();
197        std::fs::write(
198            dir.path().join("mars.toml"),
199            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
200        )
201        .unwrap();
202
203        let already = ensure_consumer_config(dir.path()).unwrap();
204        assert!(already);
205    }
206
207    #[test]
208    fn initialize_project_without_target_creates_mars_only() {
209        let dir = TempDir::new().unwrap();
210
211        let initialized = initialize_project(Some(dir.path()), None).unwrap();
212
213        assert!(dir.path().join(".mars").exists());
214        assert!(!dir.path().join(".agents").exists());
215        assert!(initialized.managed_root.is_none());
216
217        let config = crate::config::load(dir.path()).unwrap();
218        assert!(config.settings.managed_root.is_none());
219    }
220
221    #[test]
222    fn initialize_project_with_explicit_target_persists_managed_root() {
223        let dir = TempDir::new().unwrap();
224
225        let initialized = initialize_project(Some(dir.path()), Some(".claude")).unwrap();
226
227        assert!(dir.path().join(".mars").exists());
228        assert!(dir.path().join(".claude").exists());
229        assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
230
231        let config = crate::config::load(dir.path()).unwrap();
232        assert_eq!(config.settings.managed_root.as_deref(), Some(".claude"));
233    }
234
235    #[test]
236    fn initialize_project_preserves_existing_managed_root_when_no_target_given() {
237        let dir = TempDir::new().unwrap();
238        std::fs::write(
239            dir.path().join("mars.toml"),
240            "[settings]\nmanaged_root = \".claude\"\n",
241        )
242        .unwrap();
243
244        let initialized = initialize_project(Some(dir.path()), None).unwrap();
245
246        assert!(dir.path().join(".claude").exists());
247        assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
248    }
249
250    #[test]
251    fn initialize_project_with_explicit_agents_persists_deprecated_target() {
252        let dir = TempDir::new().unwrap();
253
254        let initialized = initialize_project(Some(dir.path()), Some(".agents")).unwrap();
255
256        assert!(dir.path().join(".agents").exists());
257        assert_eq!(initialized.managed_root, Some(dir.path().join(".agents")));
258
259        let config = crate::config::load(dir.path()).unwrap();
260        assert_eq!(config.settings.managed_root.as_deref(), Some(".agents"));
261    }
262}