1use serde::{Deserialize, Serialize};
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum ProjectKind {
20 App,
23 Agent,
27}
28
29impl ProjectKind {
30 pub fn as_str(&self) -> &'static str {
31 match self {
32 Self::App => "app",
33 Self::Agent => "agent",
34 }
35 }
36
37 pub fn parse(s: &str) -> Result<Self, String> {
38 match s.trim() {
39 "app" | "" => Ok(Self::App),
40 "agent" => Ok(Self::Agent),
41 other => Err(format!(
42 "unknown project kind '{other}' (expected app | agent)"
43 )),
44 }
45 }
46}
47
48#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51pub struct CoderProject {
52 pub slug: String,
53 pub display_name: String,
54 pub kind: ProjectKind,
55 pub repo_path: PathBuf,
56 pub created_at: u64,
57}
58
59fn now_secs() -> u64 {
60 std::time::SystemTime::now()
61 .duration_since(std::time::UNIX_EPOCH)
62 .map(|d| d.as_secs())
63 .unwrap_or(0)
64}
65
66pub fn projects_root() -> Result<PathBuf, String> {
70 if let Some(dir) = std::env::var_os("CAR_PROJECTS_DIR") {
71 return Ok(PathBuf::from(dir));
72 }
73 let root = car_home::root()
74 .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
75 Ok(root.join("projects"))
76}
77
78const SLUG_MAX_LEN: usize = 64;
88
89const SLUG_SUFFIX_HEX_LEN: usize = 16;
98
99pub fn slugify(name: &str) -> String {
109 let mut out = String::new();
110 let mut prev_dash = false;
111 for c in name.trim().chars() {
112 if c.is_ascii_alphanumeric() {
113 out.push(c.to_ascii_lowercase());
114 prev_dash = false;
115 } else if !prev_dash && !out.is_empty() {
116 out.push('-');
117 prev_dash = true;
118 }
119 }
120 let trimmed = out.trim_matches('-');
121 if trimmed.is_empty() {
122 return "project".to_string();
123 }
124 if trimmed.len() <= SLUG_MAX_LEN {
126 return trimmed.to_string();
127 }
128 let keep = SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1;
131 let mut capped = trimmed[..keep].trim_end_matches('-').to_string();
132 use sha2::{Digest, Sha256};
137 let digest = Sha256::digest(trimmed.as_bytes());
138 capped.push('-');
139 for byte in &digest[..SLUG_SUFFIX_HEX_LEN / 2] {
140 capped.push_str(&format!("{byte:02x}"));
141 }
142 capped
143}
144
145fn project_dir(slug: &str) -> Result<PathBuf, String> {
146 Ok(projects_root()?.join(slug))
147}
148
149#[cfg(test)]
153pub(crate) fn projects_env_lock() -> &'static std::sync::Mutex<()> {
154 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
155 LOCK.get_or_init(|| std::sync::Mutex::new(()))
156}
157
158fn git(dir: &Path, args: &[&str]) -> Result<(), String> {
159 let out = std::process::Command::new("git")
160 .arg("-C")
161 .arg(dir)
162 .args(args)
163 .output()
164 .map_err(|e| format!("git {args:?}: {e}"))?;
165 if out.status.success() {
166 Ok(())
167 } else {
168 Err(format!(
169 "git {args:?} failed: {}",
170 String::from_utf8_lossy(&out.stderr).trim()
171 ))
172 }
173}
174
175pub fn resolve_or_create_project(name: &str, kind: ProjectKind) -> Result<CoderProject, String> {
181 let slug = slugify(name);
182 let dir = project_dir(&slug)?;
183 let meta_path = dir.join("project.json");
184
185 if meta_path.exists() {
186 return load_project(&slug);
187 }
188
189 std::fs::create_dir_all(&dir)
190 .map_err(|e| format!("create project dir {}: {e}", dir.display()))?;
191 git(&dir, &["init", "-q", "-b", "main"])?;
192
193 std::fs::write(dir.join(".gitignore"), "project.json\n")
195 .map_err(|e| format!("write .gitignore: {e}"))?;
196
197 seed_project(&dir, name, kind)?;
198
199 git(
200 &dir,
201 &[
202 "-c",
203 "user.name=car-coder",
204 "-c",
205 "user.email=coder@parslee.ai",
206 "add",
207 "-A",
208 ],
209 )?;
210 git(
211 &dir,
212 &[
213 "-c",
214 "user.name=car-coder",
215 "-c",
216 "user.email=coder@parslee.ai",
217 "commit",
218 "-q",
219 "-m",
220 "Initialize project",
221 ],
222 )?;
223
224 let project = CoderProject {
225 slug: slug.clone(),
226 display_name: name.trim().to_string(),
227 kind,
228 repo_path: dir.clone(),
229 created_at: now_secs(),
230 };
231 persist(&project)?;
232 Ok(project)
233}
234
235fn seed_project(dir: &Path, name: &str, kind: ProjectKind) -> Result<(), String> {
239 let write = |rel: &str, body: &str| -> Result<(), String> {
240 let path = dir.join(rel);
241 if let Some(parent) = path.parent() {
242 std::fs::create_dir_all(parent)
243 .map_err(|e| format!("create {}: {e}", parent.display()))?;
244 }
245 std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
246 };
247 let display = name.trim();
248 write(
249 "README.md",
250 &format!("# {display}\n\nA CAR-managed project.\n"),
251 )?;
252 if kind == ProjectKind::Agent {
253 write(
256 "agent.json",
257 "{\n \"name\": \"\",\n \"identity\": \"\",\n \"tools\": [],\n \"standing_goal\": \"\"\n}\n",
258 )?;
259 write("scenarios.json", "[]\n")?;
260 write(
261 ".car/identity.md",
262 &format!("# {display}\n\nDescribe what this agent does.\n"),
263 )?;
264 }
265 Ok(())
266}
267
268fn persist(project: &CoderProject) -> Result<(), String> {
269 let path = project.repo_path.join("project.json");
270 let json = serde_json::to_string_pretty(project).map_err(|e| e.to_string())?;
271 std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
272}
273
274pub fn load_project(slug: &str) -> Result<CoderProject, String> {
276 let path = project_dir(slug)?.join("project.json");
277 let text = std::fs::read_to_string(&path)
278 .map_err(|e| format!("no project '{slug}' ({}): {e}", path.display()))?;
279 serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
280}
281
282pub fn list_projects() -> Vec<CoderProject> {
284 let Ok(root) = projects_root() else {
285 return Vec::new();
286 };
287 let Ok(entries) = std::fs::read_dir(&root) else {
288 return Vec::new();
289 };
290 let mut out: Vec<CoderProject> = entries
291 .flatten()
292 .filter(|e| e.path().is_dir())
293 .filter_map(|e| e.file_name().into_string().ok())
294 .filter_map(|slug| load_project(&slug).ok())
295 .collect();
296 out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
297 out
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 fn with_temp_root<T>(f: impl FnOnce(&Path) -> T) -> T {
307 let _guard = super::projects_env_lock()
308 .lock()
309 .unwrap_or_else(|e| e.into_inner());
310 let tmp = tempfile::tempdir().unwrap();
311 let prev = std::env::var_os("CAR_PROJECTS_DIR");
312 unsafe {
313 std::env::set_var("CAR_PROJECTS_DIR", tmp.path());
314 }
315 let out = f(tmp.path());
316 unsafe {
317 match prev {
318 Some(v) => std::env::set_var("CAR_PROJECTS_DIR", v),
319 None => std::env::remove_var("CAR_PROJECTS_DIR"),
320 }
321 }
322 out
323 }
324
325 fn git_available() -> bool {
326 std::process::Command::new("git")
327 .arg("--version")
328 .output()
329 .is_ok()
330 }
331
332 #[test]
333 fn slugify_is_filename_safe_and_stable() {
334 assert_eq!(slugify("My Email Summarizer!"), "my-email-summarizer");
335 assert_eq!(slugify(" weird___name "), "weird-name");
336 assert_eq!(slugify("Café ☕ Bot"), "caf-bot");
337 assert_eq!(slugify(""), "project");
338 assert_eq!(slugify("!!!"), "project");
339 assert_eq!(slugify("already-good-123"), "already-good-123");
340 }
341
342 #[test]
347 fn slugify_caps_long_names_within_peer_name_limit() {
348 let long = "research assistant that summarizes every morning brief and files follow-ups "
349 .repeat(6);
350 assert!(
351 long.len() > 300,
352 "fixture must actually be long: {}",
353 long.len()
354 );
355 let slug = slugify(&long);
356 assert_eq!(
357 slug.len(),
358 SLUG_MAX_LEN,
359 "cap must engage for a 300-char name"
360 );
361 assert!(car_peers::is_valid_peer_name(&slug));
362 }
363
364 #[test]
367 fn slugify_keeps_distinct_long_names_distinct() {
368 let base = "a".repeat(300);
369 let left = slugify(&format!("{base} left"));
370 let right = slugify(&format!("{base} right"));
371 assert_ne!(left, right);
372 assert!(car_peers::is_valid_peer_name(&left));
373 assert!(car_peers::is_valid_peer_name(&right));
374 }
375
376 #[test]
382 fn slugify_24_bit_collision_pair_stays_distinct() {
383 let left = slugify(&format!("{}-candidate-665", "a".repeat(60)));
384 let right = slugify(&format!("{}-candidate-880", "a".repeat(60)));
385 assert_ne!(left, right);
386 assert_eq!(left.len(), SLUG_MAX_LEN);
387 assert_eq!(right.len(), SLUG_MAX_LEN);
388 assert!(car_peers::is_valid_peer_name(&left));
389 assert!(car_peers::is_valid_peer_name(&right));
390 }
391
392 #[test]
395 fn slugify_cap_boundary_is_exact() {
396 let at_cap = "x".repeat(SLUG_MAX_LEN);
397 assert_eq!(slugify(&at_cap), at_cap);
398 let over = "y".repeat(SLUG_MAX_LEN + 1);
399 let capped = slugify(&over);
400 assert_eq!(capped.len(), SLUG_MAX_LEN);
401 assert!(capped.starts_with(&"y".repeat(SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1)));
402 assert!(car_peers::is_valid_peer_name(&capped));
403 }
404
405 #[test]
409 fn slugify_truncation_follows_normalization() {
410 let lower = slugify(&"Thing One ".repeat(30));
411 let upper = slugify(&"THING ONE ".repeat(30));
412 assert_eq!(lower, upper);
413 }
414
415 #[test]
416 fn long_agent_description_gets_a_bounded_unique_slug_without_losing_the_description() {
417 if !git_available() {
418 return;
419 }
420 with_temp_root(|root| {
421 let stem = "Build an agent that summarizes every support conversation, preserves customer commitments, identifies unresolved follow-up work, and writes focused scenarios. ";
422 let mut description = stem.to_string();
423 description.push_str(&"x".repeat(400 - description.len()));
424 assert_eq!(description.chars().count(), 400);
425
426 let project = resolve_or_create_project(&description, ProjectKind::Agent)
427 .expect("create project");
428 assert!(
429 project.slug.len() <= SLUG_MAX_LEN,
430 "bounded slug was {} bytes: {}",
431 project.slug.len(),
432 project.slug
433 );
434 assert_eq!(project.repo_path.parent(), Some(root));
435 assert_eq!(project.display_name, description);
436 assert_eq!(
437 project.repo_path.file_name().and_then(|name| name.to_str()),
438 Some(project.slug.as_str())
439 );
440
441 assert_eq!(project.slug, slugify(&description));
442 let repeated = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
443 let equivalent =
444 resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent).unwrap();
445 assert_eq!(repeated.repo_path, project.repo_path);
446 assert_eq!(equivalent.repo_path, project.repo_path);
447
448 let mut distinct_description = description.clone();
449 distinct_description.replace_range(399..400, "y");
450 let distinct = resolve_or_create_project(&distinct_description, ProjectKind::Agent)
451 .expect("create distinct project");
452 assert_ne!(project.slug, distinct.slug);
453 assert_eq!(distinct.display_name, distinct_description);
454 });
455 }
456
457 #[test]
458 fn existing_agent_slugs_at_61_through_64_chars_keep_their_identity() {
459 if !git_available() {
460 return;
461 }
462 with_temp_root(|_| {
463 for length in 61..=64 {
464 let description = "a".repeat(length);
465 let project = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
466 assert_eq!(project.slug, description);
467 let repeated =
468 resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent)
469 .unwrap();
470 assert_eq!(project.repo_path, repeated.repo_path);
471 }
472 });
473 }
474
475 #[test]
476 fn create_initializes_git_repo_and_is_idempotent() {
477 if !git_available() {
478 return;
479 }
480 with_temp_root(|_root| {
481 let p = resolve_or_create_project("My App", ProjectKind::App).unwrap();
482 assert_eq!(p.slug, "my-app");
483 assert_eq!(p.kind, ProjectKind::App);
484 assert!(p.repo_path.join(".git").exists());
485 assert!(p.repo_path.join("README.md").exists());
486 assert!(p.repo_path.join("project.json").exists());
487 let log = std::process::Command::new("git")
489 .arg("-C")
490 .arg(&p.repo_path)
491 .args(["log", "--oneline"])
492 .output()
493 .unwrap();
494 assert!(log.status.success() && !log.stdout.is_empty());
495
496 let again = resolve_or_create_project("My App", ProjectKind::Agent).unwrap();
499 assert_eq!(again.slug, p.slug);
500 assert_eq!(again.kind, ProjectKind::App, "existing kind wins");
501 assert_eq!(again.created_at, p.created_at);
502 });
503 }
504
505 #[test]
506 fn agent_project_seeds_spec_stubs() {
507 if !git_available() {
508 return;
509 }
510 with_temp_root(|_root| {
511 let p = resolve_or_create_project("Email Bot", ProjectKind::Agent).unwrap();
512 assert!(p.repo_path.join("agent.json").exists());
513 assert!(p.repo_path.join("scenarios.json").exists());
514 assert!(p.repo_path.join(".car/identity.md").exists());
515 let tracked = std::process::Command::new("git")
517 .arg("-C")
518 .arg(&p.repo_path)
519 .args(["ls-files"])
520 .output()
521 .unwrap();
522 let files = String::from_utf8_lossy(&tracked.stdout);
523 assert!(files.contains("agent.json"));
524 assert!(
525 !files.contains("project.json"),
526 "project.json must be gitignored"
527 );
528 });
529 }
530
531 #[test]
532 fn list_returns_created_projects_newest_first() {
533 if !git_available() {
534 return;
535 }
536 with_temp_root(|_root| {
537 let a = resolve_or_create_project("Alpha", ProjectKind::App).unwrap();
538 let b = resolve_or_create_project("Beta", ProjectKind::Agent).unwrap();
539 let listed = list_projects();
540 assert_eq!(listed.len(), 2);
541 let slugs: Vec<&str> = listed.iter().map(|p| p.slug.as_str()).collect();
542 assert!(slugs.contains(&a.slug.as_str()));
543 assert!(slugs.contains(&b.slug.as_str()));
544 });
545 }
546
547 #[test]
548 fn load_missing_project_errors() {
549 with_temp_root(|_root| {
550 assert!(load_project("does-not-exist").is_err());
551 });
552 }
553
554 #[test]
555 fn project_kind_round_trips() {
556 assert_eq!(ProjectKind::parse("app").unwrap(), ProjectKind::App);
557 assert_eq!(ProjectKind::parse("agent").unwrap(), ProjectKind::Agent);
558 assert_eq!(ProjectKind::parse("").unwrap(), ProjectKind::App);
559 assert!(ProjectKind::parse("widget").is_err());
560 assert_eq!(ProjectKind::App.as_str(), "app");
561 assert_eq!(ProjectKind::Agent.as_str(), "agent");
562 }
563}