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 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub existing_agent_id: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
65}
66
67fn now_secs() -> u64 {
68 std::time::SystemTime::now()
69 .duration_since(std::time::UNIX_EPOCH)
70 .map(|d| d.as_secs())
71 .unwrap_or(0)
72}
73
74pub fn projects_root() -> Result<PathBuf, String> {
78 if let Some(dir) = std::env::var_os("CAR_PROJECTS_DIR") {
79 return Ok(PathBuf::from(dir));
80 }
81 let root = car_home::root()
82 .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
83 Ok(root.join("projects"))
84}
85
86const SLUG_MAX_LEN: usize = 64;
96
97const SLUG_SUFFIX_HEX_LEN: usize = 16;
106
107pub fn slugify(name: &str) -> String {
117 let mut out = String::new();
118 let mut prev_dash = false;
119 for c in name.trim().chars() {
120 if c.is_ascii_alphanumeric() {
121 out.push(c.to_ascii_lowercase());
122 prev_dash = false;
123 } else if !prev_dash && !out.is_empty() {
124 out.push('-');
125 prev_dash = true;
126 }
127 }
128 let trimmed = out.trim_matches('-');
129 if trimmed.is_empty() {
130 return "project".to_string();
131 }
132 if trimmed.len() <= SLUG_MAX_LEN {
134 return trimmed.to_string();
135 }
136 let keep = SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1;
139 let mut capped = trimmed[..keep].trim_end_matches('-').to_string();
140 use sha2::{Digest, Sha256};
145 let digest = Sha256::digest(trimmed.as_bytes());
146 capped.push('-');
147 for byte in &digest[..SLUG_SUFFIX_HEX_LEN / 2] {
148 capped.push_str(&format!("{byte:02x}"));
149 }
150 capped
151}
152
153fn project_dir(slug: &str) -> Result<PathBuf, String> {
154 Ok(projects_root()?.join(slug))
155}
156
157#[cfg(test)]
161pub(crate) fn projects_env_lock() -> &'static std::sync::Mutex<()> {
162 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
163 LOCK.get_or_init(|| std::sync::Mutex::new(()))
164}
165
166fn git(dir: &Path, args: &[&str]) -> Result<(), String> {
167 let out = std::process::Command::new("git")
168 .arg("-C")
169 .arg(dir)
170 .args(args)
171 .output()
172 .map_err(|e| format!("git {args:?}: {e}"))?;
173 if out.status.success() {
174 Ok(())
175 } else {
176 Err(format!(
177 "git {args:?} failed: {}",
178 String::from_utf8_lossy(&out.stderr).trim()
179 ))
180 }
181}
182
183pub fn resolve_or_create_project(name: &str, kind: ProjectKind) -> Result<CoderProject, String> {
189 resolve_or_create_project_for_agent(name, kind, None, None)
190}
191
192pub fn resolve_or_create_project_for_agent(
199 name: &str,
200 kind: ProjectKind,
201 existing_agent_id: Option<String>,
202 builder_draft: Option<car_registry::declarative::AgentBuilderDraft>,
203) -> Result<CoderProject, String> {
204 if kind != ProjectKind::Agent && (existing_agent_id.is_some() || builder_draft.is_some()) {
205 return Err("existing_agent_id and builder_draft require an agent project".into());
206 }
207 let slug = slugify(name);
208 let dir = project_dir(&slug)?;
209 let meta_path = dir.join("project.json");
210
211 if meta_path.exists() {
212 let mut project = load_project(&slug)?;
213 if existing_agent_id.is_some() || builder_draft.is_some() {
214 if project.kind != ProjectKind::Agent {
215 return Err(format!(
216 "project '{}' is kind '{}'; agent edit metadata requires kind 'agent'",
217 project.slug,
218 project.kind.as_str()
219 ));
220 }
221 if let (Some(bound), Some(requested)) = (
222 project.existing_agent_id.as_deref(),
223 existing_agent_id.as_deref(),
224 ) {
225 if bound != requested {
226 return Err(format!(
227 "project '{}' is already bound to agent '{}' and cannot be rebound to '{}'",
228 project.slug, bound, requested
229 ));
230 }
231 }
232 if existing_agent_id.is_some() {
233 project.existing_agent_id = existing_agent_id;
234 }
235 if builder_draft.is_some() {
236 project.builder_draft = builder_draft;
237 }
238 persist(&project)?;
239 }
240 return Ok(project);
241 }
242
243 std::fs::create_dir_all(&dir)
244 .map_err(|e| format!("create project dir {}: {e}", dir.display()))?;
245 git(&dir, &["init", "-q", "-b", "main"])?;
246
247 std::fs::write(dir.join(".gitignore"), "project.json\n")
249 .map_err(|e| format!("write .gitignore: {e}"))?;
250
251 seed_project(&dir, name, kind)?;
252
253 git(
254 &dir,
255 &[
256 "-c",
257 "user.name=car-coder",
258 "-c",
259 "user.email=coder@parslee.ai",
260 "add",
261 "-A",
262 ],
263 )?;
264 git(
265 &dir,
266 &[
267 "-c",
268 "user.name=car-coder",
269 "-c",
270 "user.email=coder@parslee.ai",
271 "commit",
272 "-q",
273 "-m",
274 "Initialize project",
275 ],
276 )?;
277
278 let project = CoderProject {
279 slug: slug.clone(),
280 display_name: name.trim().to_string(),
281 kind,
282 repo_path: dir.clone(),
283 created_at: now_secs(),
284 existing_agent_id,
285 builder_draft,
286 };
287 persist(&project)?;
288 Ok(project)
289}
290
291fn seed_project(dir: &Path, name: &str, kind: ProjectKind) -> Result<(), String> {
295 let write = |rel: &str, body: &str| -> Result<(), String> {
296 let path = dir.join(rel);
297 if let Some(parent) = path.parent() {
298 std::fs::create_dir_all(parent)
299 .map_err(|e| format!("create {}: {e}", parent.display()))?;
300 }
301 std::fs::write(&path, body).map_err(|e| format!("write {}: {e}", path.display()))
302 };
303 let display = name.trim();
304 write(
305 "README.md",
306 &format!("# {display}\n\nA CAR-managed project.\n"),
307 )?;
308 if kind == ProjectKind::Agent {
309 write(
312 "agent.json",
313 "{\n \"name\": \"\",\n \"identity\": \"\",\n \"tools\": [],\n \"standing_goal\": \"\"\n}\n",
314 )?;
315 write("scenarios.json", "[]\n")?;
316 write(
317 ".car/identity.md",
318 &format!("# {display}\n\nDescribe what this agent does.\n"),
319 )?;
320 }
321 Ok(())
322}
323
324fn persist(project: &CoderProject) -> Result<(), String> {
325 let path = project.repo_path.join("project.json");
326 let json = serde_json::to_string_pretty(project).map_err(|e| e.to_string())?;
327 std::fs::write(&path, json).map_err(|e| format!("write {}: {e}", path.display()))
328}
329
330pub fn load_project(slug: &str) -> Result<CoderProject, String> {
332 let path = project_dir(slug)?.join("project.json");
333 let text = std::fs::read_to_string(&path)
334 .map_err(|e| format!("no project '{slug}' ({}): {e}", path.display()))?;
335 serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
336}
337
338pub fn list_projects() -> Vec<CoderProject> {
340 let Ok(root) = projects_root() else {
341 return Vec::new();
342 };
343 let Ok(entries) = std::fs::read_dir(&root) else {
344 return Vec::new();
345 };
346 let mut out: Vec<CoderProject> = entries
347 .flatten()
348 .filter(|e| e.path().is_dir())
349 .filter_map(|e| e.file_name().into_string().ok())
350 .filter_map(|slug| load_project(&slug).ok())
351 .collect();
352 out.sort_by(|a, b| b.created_at.cmp(&a.created_at));
353 out
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 fn with_temp_root<T>(f: impl FnOnce(&Path) -> T) -> T {
363 let _guard = super::projects_env_lock()
364 .lock()
365 .unwrap_or_else(|e| e.into_inner());
366 let tmp = tempfile::tempdir().unwrap();
367 let prev = std::env::var_os("CAR_PROJECTS_DIR");
368 unsafe {
369 std::env::set_var("CAR_PROJECTS_DIR", tmp.path());
370 }
371 let out = f(tmp.path());
372 unsafe {
373 match prev {
374 Some(v) => std::env::set_var("CAR_PROJECTS_DIR", v),
375 None => std::env::remove_var("CAR_PROJECTS_DIR"),
376 }
377 }
378 out
379 }
380
381 fn git_available() -> bool {
382 std::process::Command::new("git")
383 .arg("--version")
384 .output()
385 .is_ok()
386 }
387
388 #[test]
389 fn slugify_is_filename_safe_and_stable() {
390 assert_eq!(slugify("My Email Summarizer!"), "my-email-summarizer");
391 assert_eq!(slugify(" weird___name "), "weird-name");
392 assert_eq!(slugify("Café ☕ Bot"), "caf-bot");
393 assert_eq!(slugify(""), "project");
394 assert_eq!(slugify("!!!"), "project");
395 assert_eq!(slugify("already-good-123"), "already-good-123");
396 }
397
398 #[test]
399 fn descriptions_from_1_through_2000_chars_have_bounded_safe_slugs() {
400 let source: String = "Agent / WITH spaces_日本語-and.punctuation_0123456789!"
401 .chars()
402 .cycle()
403 .take(2000)
404 .collect();
405 assert_eq!(source.chars().count(), 2000);
406
407 for length in 1..=2000 {
408 let description: String = source.chars().take(length).collect();
409 let slug = slugify(&description);
410 assert!(
411 !slug.is_empty() && slug.len() <= SLUG_MAX_LEN,
412 "length {length} produced {} bytes: {slug}",
413 slug.len()
414 );
415 assert!(
416 slug.bytes()
417 .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-'),
418 "length {length} produced an unsafe slug: {slug}"
419 );
420 assert!(!slug.starts_with('-'), "length {length}: {slug}");
421 assert!(!slug.ends_with('-'), "length {length}: {slug}");
422 assert!(!slug.contains("--"), "length {length}: {slug}");
423 assert!(
424 car_peers::is_valid_peer_name(&slug),
425 "length {length} produced an invalid peer name: {slug}"
426 );
427 }
428 }
429
430 #[test]
435 fn slugify_caps_long_names_within_peer_name_limit() {
436 let long = "research assistant that summarizes every morning brief and files follow-ups "
437 .repeat(6);
438 assert!(
439 long.len() > 300,
440 "fixture must actually be long: {}",
441 long.len()
442 );
443 let slug = slugify(&long);
444 assert_eq!(
445 slug.len(),
446 SLUG_MAX_LEN,
447 "cap must engage for a 300-char name"
448 );
449 assert!(car_peers::is_valid_peer_name(&slug));
450 }
451
452 #[test]
455 fn slugify_keeps_distinct_long_names_distinct() {
456 let base = "a".repeat(300);
457 let left = slugify(&format!("{base} left"));
458 let right = slugify(&format!("{base} right"));
459 assert_ne!(left, right);
460 assert!(car_peers::is_valid_peer_name(&left));
461 assert!(car_peers::is_valid_peer_name(&right));
462 }
463
464 #[test]
470 fn slugify_24_bit_collision_pair_stays_distinct() {
471 let left = slugify(&format!("{}-candidate-665", "a".repeat(60)));
472 let right = slugify(&format!("{}-candidate-880", "a".repeat(60)));
473 assert_ne!(left, right);
474 assert_eq!(left.len(), SLUG_MAX_LEN);
475 assert_eq!(right.len(), SLUG_MAX_LEN);
476 assert!(car_peers::is_valid_peer_name(&left));
477 assert!(car_peers::is_valid_peer_name(&right));
478 }
479
480 #[test]
483 fn slugify_cap_boundary_is_exact() {
484 let at_cap = "x".repeat(SLUG_MAX_LEN);
485 assert_eq!(slugify(&at_cap), at_cap);
486 let over = "y".repeat(SLUG_MAX_LEN + 1);
487 let capped = slugify(&over);
488 assert_eq!(capped.len(), SLUG_MAX_LEN);
489 assert!(capped.starts_with(&"y".repeat(SLUG_MAX_LEN - SLUG_SUFFIX_HEX_LEN - 1)));
490 assert!(car_peers::is_valid_peer_name(&capped));
491 }
492
493 #[test]
497 fn slugify_truncation_follows_normalization() {
498 let lower = slugify(&"Thing One ".repeat(30));
499 let upper = slugify(&"THING ONE ".repeat(30));
500 assert_eq!(lower, upper);
501 }
502
503 #[test]
504 fn long_agent_description_gets_a_bounded_unique_slug_without_losing_the_description() {
505 if !git_available() {
506 return;
507 }
508 with_temp_root(|root| {
509 let stem = "Build an agent that summarizes every support conversation, preserves customer commitments, identifies unresolved follow-up work, and writes focused scenarios. ";
510 let mut description = stem.to_string();
511 description.push_str(&"x".repeat(400 - description.len()));
512 assert_eq!(description.chars().count(), 400);
513
514 let project = resolve_or_create_project(&description, ProjectKind::Agent)
515 .expect("create project");
516 assert!(
517 project.slug.len() <= SLUG_MAX_LEN,
518 "bounded slug was {} bytes: {}",
519 project.slug.len(),
520 project.slug
521 );
522 assert_eq!(project.repo_path.parent(), Some(root));
523 assert_eq!(project.display_name, description);
524 assert_eq!(
525 project.repo_path.file_name().and_then(|name| name.to_str()),
526 Some(project.slug.as_str())
527 );
528
529 assert_eq!(project.slug, slugify(&description));
530 let repeated = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
531 let equivalent =
532 resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent).unwrap();
533 assert_eq!(repeated.repo_path, project.repo_path);
534 assert_eq!(equivalent.repo_path, project.repo_path);
535
536 let mut distinct_description = description.clone();
537 distinct_description.replace_range(399..400, "y");
538 let distinct = resolve_or_create_project(&distinct_description, ProjectKind::Agent)
539 .expect("create distinct project");
540 assert_ne!(project.slug, distinct.slug);
541 assert_eq!(distinct.display_name, distinct_description);
542 });
543 }
544
545 #[test]
546 fn existing_agent_slugs_at_61_through_64_chars_keep_their_identity() {
547 if !git_available() {
548 return;
549 }
550 with_temp_root(|_| {
551 for length in 61..=64 {
552 let description = "a".repeat(length);
553 let project = resolve_or_create_project(&description, ProjectKind::Agent).unwrap();
554 assert_eq!(project.slug, description);
555 let repeated =
556 resolve_or_create_project(&description.to_uppercase(), ProjectKind::Agent)
557 .unwrap();
558 assert_eq!(project.repo_path, repeated.repo_path);
559 }
560 });
561 }
562
563 #[test]
564 fn create_initializes_git_repo_and_is_idempotent() {
565 if !git_available() {
566 return;
567 }
568 with_temp_root(|_root| {
569 let p = resolve_or_create_project("My App", ProjectKind::App).unwrap();
570 assert_eq!(p.slug, "my-app");
571 assert_eq!(p.kind, ProjectKind::App);
572 assert!(p.repo_path.join(".git").exists());
573 assert!(p.repo_path.join("README.md").exists());
574 assert!(p.repo_path.join("project.json").exists());
575 let log = std::process::Command::new("git")
577 .arg("-C")
578 .arg(&p.repo_path)
579 .args(["log", "--oneline"])
580 .output()
581 .unwrap();
582 assert!(log.status.success() && !log.stdout.is_empty());
583
584 let again = resolve_or_create_project("My App", ProjectKind::Agent).unwrap();
587 assert_eq!(again.slug, p.slug);
588 assert_eq!(again.kind, ProjectKind::App, "existing kind wins");
589 assert_eq!(again.created_at, p.created_at);
590 });
591 }
592
593 #[test]
594 fn agent_project_persists_builder_draft_and_cannot_rebind_existing_agent() {
595 if !git_available() {
596 return;
597 }
598 with_temp_root(|_root| {
599 let draft = car_registry::declarative::AgentBuilderDraft {
600 template_id: "inboxBrief".into(),
601 name: "Inbox Brief".into(),
602 responsibility: "Summarize mail".into(),
603 example: "Flag replies due today".into(),
604 access: "Read connected email".into(),
605 cadence: "Weekday mornings".into(),
606 delivery: "Save in Work".into(),
607 privacy: "Never send automatically".into(),
608 };
609 let project = resolve_or_create_project_for_agent(
610 "Inbox Brief",
611 ProjectKind::Agent,
612 Some("inbox-agent".into()),
613 Some(draft.clone()),
614 )
615 .unwrap();
616 let loaded = load_project(&project.slug).unwrap();
617 assert_eq!(loaded.existing_agent_id.as_deref(), Some("inbox-agent"));
618 assert_eq!(loaded.builder_draft, Some(draft));
619
620 let error = resolve_or_create_project_for_agent(
621 "Inbox Brief",
622 ProjectKind::Agent,
623 Some("different-agent".into()),
624 None,
625 )
626 .unwrap_err();
627 assert!(error.contains("cannot be rebound"), "{error}");
628 });
629 }
630
631 #[test]
632 fn agent_project_seeds_spec_stubs() {
633 if !git_available() {
634 return;
635 }
636 with_temp_root(|_root| {
637 let p = resolve_or_create_project("Email Bot", ProjectKind::Agent).unwrap();
638 assert!(p.repo_path.join("agent.json").exists());
639 assert!(p.repo_path.join("scenarios.json").exists());
640 assert!(p.repo_path.join(".car/identity.md").exists());
641 let tracked = std::process::Command::new("git")
643 .arg("-C")
644 .arg(&p.repo_path)
645 .args(["ls-files"])
646 .output()
647 .unwrap();
648 let files = String::from_utf8_lossy(&tracked.stdout);
649 assert!(files.contains("agent.json"));
650 assert!(
651 !files.contains("project.json"),
652 "project.json must be gitignored"
653 );
654 });
655 }
656
657 #[test]
658 fn list_returns_created_projects_newest_first() {
659 if !git_available() {
660 return;
661 }
662 with_temp_root(|_root| {
663 let a = resolve_or_create_project("Alpha", ProjectKind::App).unwrap();
664 let b = resolve_or_create_project("Beta", ProjectKind::Agent).unwrap();
665 let listed = list_projects();
666 assert_eq!(listed.len(), 2);
667 let slugs: Vec<&str> = listed.iter().map(|p| p.slug.as_str()).collect();
668 assert!(slugs.contains(&a.slug.as_str()));
669 assert!(slugs.contains(&b.slug.as_str()));
670 });
671 }
672
673 #[test]
674 fn load_missing_project_errors() {
675 with_temp_root(|_root| {
676 assert!(load_project("does-not-exist").is_err());
677 });
678 }
679
680 #[test]
681 fn project_kind_round_trips() {
682 assert_eq!(ProjectKind::parse("app").unwrap(), ProjectKind::App);
683 assert_eq!(ProjectKind::parse("agent").unwrap(), ProjectKind::Agent);
684 assert_eq!(ProjectKind::parse("").unwrap(), ProjectKind::App);
685 assert!(ProjectKind::parse("widget").is_err());
686 assert_eq!(ProjectKind::App.as_str(), "app");
687 assert_eq!(ProjectKind::Agent.as_str(), "agent");
688 }
689}