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