1use std::path::{Path, PathBuf};
11
12use crate::error::{ConfigError, MarsError};
13
14use super::output;
15
16#[derive(Debug, clap::Args)]
18pub struct InitArgs {
19 pub target: Option<String>,
21
22 #[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
34fn 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
101pub 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 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
146fn 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 }
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}