1use std::path::{Path, PathBuf};
12
13use crate::error::{ConfigError, MarsError};
14
15use super::output;
16
17#[derive(Debug, clap::Args)]
19pub struct InitArgs {
20 pub target: Option<String>,
22
23 #[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 validate_target(target: &str) -> Result<(), MarsError> {
37 if target.contains('/') || target.contains('\\') {
38 return Err(MarsError::Config(ConfigError::Invalid {
39 message: format!(
40 "`{target}` looks like a path — TARGET should be a directory name \
41 like `.claude` or `.codex`. Use `--root` to specify project root."
42 ),
43 }));
44 }
45 if target == "." || target == ".." || target.is_empty() {
46 return Err(MarsError::Config(ConfigError::Invalid {
47 message: format!(
48 "`{target}` is not a valid target name — use a directory name like `.claude` or `.codex`."
49 ),
50 }));
51 }
52 Ok(())
53}
54
55fn ensure_consumer_config(project_root: &Path) -> Result<bool, MarsError> {
56 let config_path = project_root.join("mars.toml");
57 if config_path.exists() {
58 return Ok(true);
59 }
60
61 crate::fs::atomic_write(&config_path, b"[dependencies]\n")?;
62 Ok(false)
63}
64
65pub(super) fn initialize_project(
66 explicit_root: Option<&Path>,
67 target_override: Option<&str>,
68) -> Result<InitializedProject, MarsError> {
69 let project_root = explicit_root
70 .map(Path::to_path_buf)
71 .unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory"));
72
73 std::fs::create_dir_all(project_root.join(".mars"))?;
74
75 let already_initialized = ensure_consumer_config(&project_root)?;
76
77 let managed_root = if let Some(target) = explicit_init_target(&project_root, target_override)? {
78 validate_target(&target)?;
79 let managed_root = project_root.join(&target);
80 std::fs::create_dir_all(&managed_root)?;
81 persist_managed_root(&project_root, Some(&target))?;
82 Some(managed_root)
83 } else {
84 persist_managed_root(&project_root, None)?;
85 None
86 };
87
88 Ok(InitializedProject {
89 project_root,
90 managed_root,
91 already_initialized,
92 })
93}
94
95pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
99 let initialized = initialize_project(explicit_root, args.target.as_deref())?;
100 let project_root = initialized.project_root;
101 let managed_root = initialized.managed_root;
102 let already_initialized = initialized.already_initialized;
103
104 if !json {
105 if already_initialized {
106 output::print_info(&format!("{} already initialized", project_root.display()));
107 } else {
108 output::print_success(&format!(
109 "initialized {} with mars.toml",
110 project_root.display()
111 ));
112 }
113 }
114
115 if !args.link.is_empty() {
117 let context_managed_root = managed_root
118 .clone()
119 .unwrap_or_else(|| project_root.join(".mars"));
120 let ctx = super::MarsContext::from_roots(project_root.clone(), context_managed_root)?;
121 for link_target in &args.link {
122 let link_args = super::link::LinkArgs {
123 target: link_target.clone(),
124 unlink: false,
125 };
126 super::link::run(&link_args, &ctx, json)?;
127 }
128 }
129
130 if json {
131 output::print_json(&serde_json::json!({
132 "ok": true,
133 "project_root": project_root.to_string_lossy(),
134 "managed_root": managed_root.as_ref().map(|path| path.to_string_lossy().to_string()),
135 "already_initialized": already_initialized,
136 "links": args.link,
137 }));
138 }
139
140 Ok(0)
141}
142
143fn explicit_init_target(
144 project_root: &Path,
145 target_override: Option<&str>,
146) -> Result<Option<String>, MarsError> {
147 if let Some(target) = target_override {
148 return Ok(Some(target.to_string()));
149 }
150
151 match crate::config::load(project_root) {
152 Ok(config) => Ok(config.settings.managed_root),
153 Err(MarsError::Config(ConfigError::NotFound { .. })) => Ok(None),
154 Err(e) => Err(e),
155 }
156}
157
158fn persist_managed_root(project_root: &Path, target: Option<&str>) -> Result<(), MarsError> {
160 match crate::config::load(project_root) {
161 Ok(mut config) => {
162 config.settings.managed_root = target.map(str::to_string);
163 crate::config::save(project_root, &config)?;
164 }
165 Err(MarsError::Config(ConfigError::NotFound { .. })) => {
166 }
168 Err(e) => return Err(e),
169 }
170 Ok(())
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176 use tempfile::TempDir;
177
178 #[test]
179 fn validate_target_accepts_simple_names() {
180 assert!(validate_target(".agents").is_ok());
181 assert!(validate_target(".claude").is_ok());
182 assert!(validate_target("my-agents").is_ok());
183 }
184
185 #[test]
186 fn validate_target_rejects_paths() {
187 assert!(validate_target("./foo").is_err());
188 assert!(validate_target("foo/bar").is_err());
189 assert!(validate_target("/absolute/path").is_err());
190 }
191
192 #[test]
193 fn validate_target_rejects_dots() {
194 assert!(validate_target(".").is_err());
195 assert!(validate_target("..").is_err());
196 }
197
198 #[test]
199 fn validate_target_rejects_empty() {
200 assert!(validate_target("").is_err());
201 }
202
203 #[test]
204 fn ensure_consumer_config_creates_root_mars_toml() {
205 let dir = TempDir::new().unwrap();
206
207 let already = ensure_consumer_config(dir.path()).unwrap();
208 assert!(!already);
209
210 let content = std::fs::read_to_string(dir.path().join("mars.toml")).unwrap();
211 assert!(content.contains("[dependencies]"));
212 }
213
214 #[test]
215 fn ensure_consumer_config_accepts_existing_mars_toml() {
216 let dir = TempDir::new().unwrap();
217 std::fs::write(
218 dir.path().join("mars.toml"),
219 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
220 )
221 .unwrap();
222
223 let already = ensure_consumer_config(dir.path()).unwrap();
224 assert!(already);
225 }
226
227 #[test]
228 fn initialize_project_without_target_creates_mars_only() {
229 let dir = TempDir::new().unwrap();
230
231 let initialized = initialize_project(Some(dir.path()), None).unwrap();
232
233 assert!(dir.path().join(".mars").exists());
234 assert!(!dir.path().join(".agents").exists());
235 assert!(initialized.managed_root.is_none());
236
237 let config = crate::config::load(dir.path()).unwrap();
238 assert!(config.settings.managed_root.is_none());
239 }
240
241 #[test]
242 fn initialize_project_with_explicit_target_persists_managed_root() {
243 let dir = TempDir::new().unwrap();
244
245 let initialized = initialize_project(Some(dir.path()), Some(".claude")).unwrap();
246
247 assert!(dir.path().join(".mars").exists());
248 assert!(dir.path().join(".claude").exists());
249 assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
250
251 let config = crate::config::load(dir.path()).unwrap();
252 assert_eq!(config.settings.managed_root.as_deref(), Some(".claude"));
253 }
254
255 #[test]
256 fn initialize_project_preserves_existing_managed_root_when_no_target_given() {
257 let dir = TempDir::new().unwrap();
258 std::fs::write(
259 dir.path().join("mars.toml"),
260 "[settings]\nmanaged_root = \".claude\"\n",
261 )
262 .unwrap();
263
264 let initialized = initialize_project(Some(dir.path()), None).unwrap();
265
266 assert!(dir.path().join(".claude").exists());
267 assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
268 }
269
270 #[test]
271 fn initialize_project_with_explicit_agents_persists_deprecated_target() {
272 let dir = TempDir::new().unwrap();
273
274 let initialized = initialize_project(Some(dir.path()), Some(".agents")).unwrap();
275
276 assert!(dir.path().join(".agents").exists());
277 assert_eq!(initialized.managed_root, Some(dir.path().join(".agents")));
278
279 let config = crate::config::load(dir.path()).unwrap();
280 assert_eq!(config.settings.managed_root.as_deref(), Some(".agents"));
281 }
282}