1#![allow(dead_code)]
26
27use std::collections::{BTreeMap, HashSet};
28use std::fs;
29use std::path::{Path, PathBuf};
30use std::process::Command;
31use std::sync::{Arc, RwLock};
32use std::time::SystemTime;
33
34use anyhow::{anyhow, Context, Result};
35use serde::{Deserialize, Serialize};
36use serde_json::json;
37
38fn validate_repo_name(name: &str) -> Result<()> {
40 let mut parts = name.split('/');
41 let org = parts.next().unwrap_or("");
42 let repo = parts.next().unwrap_or("");
43 if parts.next().is_some() || org.is_empty() || repo.is_empty() {
44 return Err(anyhow!(
45 "Invalid repo name {name:?}. Expected 'org/repo' (exactly one slash)."
46 ));
47 }
48 let valid = |s: &str| {
49 !s.is_empty()
50 && s.chars()
51 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
52 };
53 if !valid(org) || !valid(repo) {
54 return Err(anyhow!(
55 "Invalid repo name {name:?}. Letters/digits/dots/hyphens/underscores only."
56 ));
57 }
58 Ok(())
59}
60
61pub type PostActivateHook = Arc<dyn Fn(&Path, &str) -> Result<()> + Send + Sync>;
65
66pub type ActivationSummaryHook = Arc<dyn Fn(&Path, &str) -> Option<String> + Send + Sync>;
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81struct InventoryEntry {
82 cloned_at: String,
83 last_accessed: String,
84 #[serde(default)]
85 access_count: u64,
86 #[serde(default)]
87 stale: bool,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
94 last_built_sha: Option<String>,
95}
96
97pub use crate::server::manifest::WorkspaceKind;
100
101#[derive(Clone)]
103pub struct Workspace {
104 inner: Arc<WorkspaceInner>,
105}
106
107struct WorkspaceInner {
108 kind: WorkspaceKind,
109 workspace_dir: PathBuf,
110 stale_after_days: u32,
111 state: RwLock<WorkspaceState>,
112 post_activate: Option<PostActivateHook>,
113 activation_summary: Option<ActivationSummaryHook>,
116}
117
118#[derive(Debug, Default)]
119struct WorkspaceState {
120 active_repo_name: Option<String>,
121 active_repo_path: Option<PathBuf>,
122 hydrated_this_process: HashSet<String>,
130}
131
132impl Workspace {
133 pub fn open(
135 workspace_dir: PathBuf,
136 stale_after_days: u32,
137 post_activate: Option<PostActivateHook>,
138 ) -> Result<Self> {
139 if !workspace_dir.is_dir() {
140 fs::create_dir_all(&workspace_dir).with_context(|| {
141 format!("failed to create workspace dir {}", workspace_dir.display())
142 })?;
143 }
144 let repos_dir = workspace_dir.join("repos");
145 if !repos_dir.is_dir() {
146 fs::create_dir_all(&repos_dir)
147 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
148 }
149 let ws = Self {
150 inner: Arc::new(WorkspaceInner {
151 kind: WorkspaceKind::Github,
152 workspace_dir,
153 stale_after_days,
154 state: RwLock::new(WorkspaceState::default()),
155 post_activate,
156 activation_summary: None,
157 }),
158 };
159 ws.reconcile_inventory()?;
160 Ok(ws)
161 }
162
163 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
171 if !root.is_dir() {
172 anyhow::bail!(
173 "local workspace root does not exist or is not a directory: {}",
174 root.display()
175 );
176 }
177 let canon_root = root
178 .canonicalize()
179 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
180 let inv_dir = canon_root.join(".mcp-workspace");
183 if !inv_dir.is_dir() {
184 fs::create_dir_all(&inv_dir).with_context(|| {
185 format!("failed to create local-workspace dir {}", inv_dir.display())
186 })?;
187 }
188 let mut state = WorkspaceState::default();
189 let synthetic_name = synthesize_local_name(&canon_root);
190 state.active_repo_name = Some(synthetic_name);
191 state.active_repo_path = Some(canon_root.clone());
192 Ok(Self {
193 inner: Arc::new(WorkspaceInner {
194 kind: WorkspaceKind::Local,
195 workspace_dir: canon_root,
196 stale_after_days: u32::MAX, state: RwLock::new(state),
198 post_activate,
199 activation_summary: None,
200 }),
201 })
202 }
203
204 pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
210 match Arc::get_mut(&mut self.inner) {
211 Some(inner) => inner.activation_summary = Some(hook),
212 None => tracing::warn!(
213 "with_activation_summary called after the workspace was cloned; summary not attached"
214 ),
215 }
216 self
217 }
218
219 pub fn kind(&self) -> WorkspaceKind {
220 self.inner.kind
221 }
222
223 pub fn workspace_dir(&self) -> &Path {
224 &self.inner.workspace_dir
225 }
226
227 pub fn repos_dir(&self) -> PathBuf {
228 self.inner.workspace_dir.join("repos")
229 }
230
231 fn inventory_path(&self) -> PathBuf {
232 match self.inner.kind {
233 WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
234 WorkspaceKind::Local => self
235 .inner
236 .workspace_dir
237 .join(".mcp-workspace")
238 .join("inventory.json"),
239 }
240 }
241
242 pub fn active_repo_name(&self) -> Option<String> {
244 self.inner.state.read().unwrap().active_repo_name.clone()
245 }
246
247 pub fn active_repo_path(&self) -> Option<PathBuf> {
249 self.inner.state.read().unwrap().active_repo_path.clone()
250 }
251
252 pub fn default_github_repo(&self) -> Option<String> {
261 match self.inner.kind {
262 WorkspaceKind::Github => self.active_repo_name(),
263 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
264 }
265 }
266
267 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
272 let path = self.inventory_path();
273 let Ok(text) = fs::read_to_string(&path) else {
274 return BTreeMap::new();
275 };
276 serde_json::from_str(&text).unwrap_or_default()
277 }
278
279 fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
280 let path = self.inventory_path();
281 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
282 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
283 Ok(())
284 }
285
286 fn reconcile_inventory(&self) -> Result<()> {
287 let mut inv = self.load_inventory();
288 let mut on_disk: Vec<String> = Vec::new();
289 if self.repos_dir().is_dir() {
290 for org_entry in fs::read_dir(self.repos_dir())? {
291 let Ok(org_entry) = org_entry else { continue };
292 if !org_entry.path().is_dir() {
293 continue;
294 }
295 let org = org_entry.file_name().to_string_lossy().into_owned();
296 if org.starts_with('.') {
297 continue;
298 }
299 for repo_entry in fs::read_dir(org_entry.path())? {
300 let Ok(repo_entry) = repo_entry else { continue };
301 if !repo_entry.path().is_dir() {
302 continue;
303 }
304 let repo = repo_entry.file_name().to_string_lossy().into_owned();
305 if repo.starts_with('.') {
306 continue;
307 }
308 let rname = format!("{org}/{repo}");
309 on_disk.push(rname.clone());
310 inv.entry(rname).or_insert_with(|| {
311 let mtime = repo_entry
312 .metadata()
313 .ok()
314 .and_then(|m| m.modified().ok())
315 .map(format_iso)
316 .unwrap_or_else(now_iso);
317 InventoryEntry {
318 cloned_at: mtime.clone(),
319 last_accessed: mtime,
320 access_count: 0,
321 stale: false,
322 last_built_sha: None,
323 }
324 });
325 }
326 }
327 }
328 for (rname, entry) in inv.iter_mut() {
329 if !on_disk.contains(rname) && !entry.stale {
330 entry.stale = true;
331 }
332 }
333 self.save_inventory(&inv)?;
334 Ok(())
335 }
336
337 fn bump_access(&self, name: &str, action: &str) {
338 let mut inv = self.load_inventory();
339 let now = now_iso();
340 let entry = inv
341 .entry(name.to_string())
342 .or_insert_with(|| InventoryEntry {
343 cloned_at: now.clone(),
344 last_accessed: now.clone(),
345 access_count: 0,
346 stale: false,
347 last_built_sha: None,
348 });
349 entry.last_accessed = now.clone();
350 entry.access_count += 1;
351 entry.stale = false;
352 if action == "cloned" || entry.cloned_at.is_empty() {
353 entry.cloned_at = now;
354 }
355 let _ = self.save_inventory(&inv);
356 }
357
358 fn mark_stale(&self, name: &str) {
359 let mut inv = self.load_inventory();
360 if let Some(entry) = inv.get_mut(name) {
361 entry.stale = true;
362 let _ = self.save_inventory(&inv);
363 }
364 }
365
366 fn sweep_stale(&self) -> Vec<String> {
367 if matches!(self.inner.kind, WorkspaceKind::Local) {
369 return Vec::new();
370 }
371 let mut inv = self.load_inventory();
372 let cutoff = SystemTime::now()
373 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
374 let active = self.active_repo_name();
375 let mut swept: Vec<String> = Vec::new();
376 for (rname, entry) in inv.iter_mut() {
377 if entry.stale {
378 continue;
379 }
380 if Some(rname.as_str()) == active.as_deref() {
381 continue;
382 }
383 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
384 if last >= cutoff {
385 continue;
386 }
387 let parts: Vec<&str> = rname.splitn(2, '/').collect();
388 if parts.len() != 2 {
389 continue;
390 }
391 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
392 if repo_path.exists() {
393 let _ = fs::remove_dir_all(&repo_path);
394 }
395 entry.stale = true;
396 swept.push(rname.clone());
397 }
398 if !swept.is_empty() {
399 let _ = self.save_inventory(&inv);
400 self.prune_empty_org_dirs();
401 }
402 swept
403 }
404
405 fn prune_empty_org_dirs(&self) {
406 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
407 return;
408 };
409 for entry in entries.flatten() {
410 let path = entry.path();
411 if !path.is_dir() {
412 continue;
413 }
414 if let Ok(children) = fs::read_dir(&path) {
415 let real: Vec<_> = children
416 .flatten()
417 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
418 .collect();
419 if real.is_empty() {
420 let _ = fs::remove_dir_all(&path);
421 }
422 }
423 }
424 }
425
426 fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
437 if matches!(self.inner.kind, WorkspaceKind::Local) {
438 let root = self
448 .inner
449 .state
450 .read()
451 .unwrap()
452 .active_repo_path
453 .clone()
454 .unwrap_or_else(|| self.inner.workspace_dir.clone());
455 let prev_sha = self.last_built_sha(name);
456 let fingerprint = fingerprint_dir(&root);
457 let action = match prev_sha {
458 Some(p) if p == fingerprint => "current",
459 None => "cloned", Some(_) => "updated",
461 };
462 return Ok((action.to_string(), root, fingerprint));
463 }
464 let parts: Vec<&str> = name.splitn(2, '/').collect();
465 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
466 if !repo_path.exists() {
467 fs::create_dir_all(repo_path.parent().unwrap()).ok();
468 let url = format!("https://github.com/{name}.git");
469 let out = Command::new("git")
470 .args(["clone", "--depth", "1", &url, repo_path.to_str().unwrap()])
471 .output()
472 .context("failed to spawn `git clone`")?;
473 if !out.status.success() {
474 anyhow::bail!(
475 "git clone failed: {}",
476 String::from_utf8_lossy(&out.stderr).trim()
477 );
478 }
479 let sha = git_rev_parse(&repo_path, "HEAD")?;
480 return Ok(("cloned".to_string(), repo_path, sha));
481 }
482
483 Command::new("git")
485 .args(["fetch", "--depth", "1", "origin"])
486 .current_dir(&repo_path)
487 .output()
488 .context("git fetch failed")?;
489 let local = git_rev_parse(&repo_path, "HEAD")?;
490 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
491 if local != remote {
492 Command::new("git")
493 .args(["reset", "--hard", "FETCH_HEAD"])
494 .current_dir(&repo_path)
495 .output()
496 .context("git reset failed")?;
497 let sha = git_rev_parse(&repo_path, "HEAD")?;
498 return Ok(("updated".to_string(), repo_path, sha));
499 }
500 Ok(("current".to_string(), repo_path, local))
501 }
502
503 fn activate(&self, name: &str, force_rebuild: bool) -> Result<String> {
516 let prev_built_sha = self.last_built_sha(name);
517 let (action, repo_path, head_sha) = self.clone_or_update(name)?;
518 self.bump_access(name, &action);
519 let hook_fired_here = {
520 let mut state = self.inner.state.write().unwrap();
521 state.active_repo_name = Some(name.to_string());
522 state.active_repo_path = Some(repo_path.clone());
523 state.hydrated_this_process.contains(name)
524 };
525
526 let already_built = !force_rebuild
534 && action == "current"
535 && prev_built_sha.as_deref() == Some(head_sha.as_str())
536 && hook_fired_here;
537 let mut hook_skipped = false;
538 let hook_ok = if already_built {
539 hook_skipped = true;
540 true
541 } else if let Some(hook) = &self.inner.post_activate {
542 match hook(&repo_path, name) {
543 Ok(()) => true,
544 Err(e) => {
545 tracing::warn!("post-activate hook for {name} failed: {e}");
546 false
547 }
548 }
549 } else {
550 true
553 };
554 if hook_ok {
555 self.record_built_sha(name, &head_sha);
556 }
557 if hook_ok && !hook_skipped {
562 self.inner
563 .state
564 .write()
565 .unwrap()
566 .hydrated_this_process
567 .insert(name.to_string());
568 }
569 let verb = match action.as_str() {
570 "cloned" => "Cloned",
571 "updated" => "Updated",
572 "current" => "Activated (already up to date)",
573 other => other,
574 };
575 let suffix = if hook_skipped {
576 " [build skipped: HEAD matches last-built SHA]"
577 } else {
578 ""
579 };
580 let base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
581 let summary = if hook_ok {
587 self.inner
588 .activation_summary
589 .as_ref()
590 .and_then(|h| h(&repo_path, name))
591 } else {
592 None
593 };
594 Ok(match summary {
595 Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
596 _ => base,
597 })
598 }
599
600 fn record_built_sha(&self, name: &str, sha: &str) {
601 let mut inv = self.load_inventory();
602 if let Some(entry) = inv.get_mut(name) {
603 entry.last_built_sha = Some(sha.to_string());
604 let _ = self.save_inventory(&inv);
605 }
606 }
607
608 pub fn last_built_sha(&self, name: &str) -> Option<String> {
613 self.load_inventory()
614 .get(name)
615 .and_then(|e| e.last_built_sha.clone())
616 }
617
618 fn delete(&self, name: &str) -> Result<String> {
619 let parts: Vec<&str> = name.splitn(2, '/').collect();
620 if parts.len() != 2 {
621 anyhow::bail!("Invalid repo name");
622 }
623 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
624 let mut deleted = Vec::new();
625 if repo_path.exists() {
626 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
627 deleted.push("repo");
628 }
629 self.mark_stale(name);
630 self.prune_empty_org_dirs();
631 if deleted.is_empty() {
632 return Ok(format!("Nothing to delete — '{name}' not found."));
633 }
634 let mut state = self.inner.state.write().unwrap();
635 if state.active_repo_name.as_deref() == Some(name) {
636 state.active_repo_name = None;
637 state.active_repo_path = None;
638 return Ok(format!(
639 "Deleted {}. Active repo cleared.",
640 deleted.join(", ")
641 ));
642 }
643 Ok(format!("Deleted {}.", deleted.join(", ")))
644 }
645
646 fn list(&self) -> String {
647 let inv = self.load_inventory();
648 if inv.is_empty() {
649 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
650 .to_string();
651 }
652 let active = self.active_repo_name();
653 let mut live: Vec<String> = Vec::new();
654 let mut stale_lines: Vec<String> = Vec::new();
655 for (rname, entry) in &inv {
656 let marker = if Some(rname.as_str()) == active.as_deref() {
657 " [active]"
658 } else {
659 ""
660 };
661 let access = format!(
662 "{} access{}, last {}",
663 entry.access_count,
664 if entry.access_count == 1 { "" } else { "es" },
665 relative_time(&entry.last_accessed)
666 );
667 if entry.stale {
668 stale_lines.push(format!(
669 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
670 ));
671 } else {
672 live.push(format!(" {rname}{marker} ({access})"));
673 }
674 }
675 let mut out = String::new();
676 if !live.is_empty() {
677 out.push_str(&format!(
678 "{} live repo(s):\n{}",
679 live.len(),
680 live.join("\n")
681 ));
682 }
683 if !stale_lines.is_empty() {
684 if !out.is_empty() {
685 out.push_str("\n\n");
686 }
687 out.push_str(&format!(
688 "{} stale repo(s):\n{}",
689 stale_lines.len(),
690 stale_lines.join("\n")
691 ));
692 }
693 out
694 }
695
696 pub fn repo_management(
710 &self,
711 name: Option<&str>,
712 delete: bool,
713 update: bool,
714 force_rebuild: bool,
715 ) -> String {
716 if matches!(self.inner.kind, WorkspaceKind::Local) {
718 if name.is_some() {
719 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
720 to switch the active root, or pass `update=true` / `force_rebuild=true` \
721 to rebuild against the current root."
722 .to_string();
723 }
724 if delete {
725 return "Local-workspace mode does not support `delete`. The root is owned by the \
726 operator; remove it manually."
727 .to_string();
728 }
729 let active = match self.active_repo_name() {
730 Some(n) => n,
731 None => return "No active local root.".to_string(),
732 };
733 let _ = update; return self
740 .activate(&active, force_rebuild)
741 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
742 }
743
744 let swept = self.sweep_stale();
745 let prefix = if swept.is_empty() {
746 String::new()
747 } else {
748 format!(
749 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
750 swept.len(),
751 self.inner.stale_after_days,
752 swept.join(", ")
753 )
754 };
755
756 if name.is_none() && !update {
757 return prefix + &self.list();
758 }
759
760 if update {
761 let Some(active) = self.active_repo_name() else {
762 return prefix + "No active repository. Call repo_management('org/repo') first.";
763 };
764 return prefix
765 + &self
766 .activate(&active, force_rebuild)
767 .unwrap_or_else(|e| format!("update failed: {e}"));
768 }
769
770 let Some(name) = name else {
771 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
772 };
773 if let Err(e) = validate_repo_name(name) {
774 return prefix + &e.to_string();
775 }
776 if delete {
777 return prefix
778 + &self
779 .delete(name)
780 .unwrap_or_else(|e| format!("delete failed: {e}"));
781 }
782 prefix
783 + &self
784 .activate(name, force_rebuild)
785 .unwrap_or_else(|e| format!("activate failed: {e}"))
786 }
787
788 pub fn set_root_dir(&self, new_root: &Path) -> String {
791 if !matches!(self.inner.kind, WorkspaceKind::Local) {
792 return "set_root_dir is only valid in local-workspace mode.".to_string();
793 }
794 if !new_root.is_dir() {
795 return format!(
796 "Path does not exist or is not a directory: {}",
797 new_root.display()
798 );
799 }
800 let canon = match new_root.canonicalize() {
801 Ok(p) => p,
802 Err(e) => return format!("canonicalize failed: {e}"),
803 };
804 let synthetic = synthesize_local_name(&canon);
805 {
806 let mut state = self.inner.state.write().unwrap();
807 state.active_repo_name = Some(synthetic.clone());
808 state.active_repo_path = Some(canon.clone());
809 }
810 self.activate(&synthetic, false)
814 .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
815 }
816}
817
818fn synthesize_local_name(root: &Path) -> String {
822 let name = root
823 .file_name()
824 .map(|s| s.to_string_lossy().into_owned())
825 .unwrap_or_else(|| "local".to_string());
826 format!("local/{name}")
827}
828
829fn parse_origin_repo(root: &Path) -> Option<String> {
840 let out = Command::new("git")
841 .arg("-C")
842 .arg(root)
843 .args(["remote", "get-url", "origin"])
844 .output()
845 .ok()?;
846 if !out.status.success() {
847 return None;
848 }
849 let url = String::from_utf8(out.stdout).ok()?;
850 parse_github_remote(url.trim())
851}
852
853fn parse_github_remote(url: &str) -> Option<String> {
856 let path = url
860 .strip_prefix("git@github.com:")
861 .or_else(|| url.strip_prefix("https://github.com/"))
862 .or_else(|| url.strip_prefix("http://github.com/"))
863 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
864 let path = path.strip_suffix(".git").unwrap_or(path);
865 let path = path.trim_end_matches('/');
866 let mut parts = path.split('/');
868 let org = parts.next().filter(|s| !s.is_empty())?;
869 let repo = parts.next().filter(|s| !s.is_empty())?;
870 if parts.next().is_some() {
871 return None;
872 }
873 Some(format!("{org}/{repo}"))
874}
875
876fn fingerprint_dir(root: &Path) -> String {
881 use std::hash::{Hash, Hasher};
882 let mut hasher = std::collections::hash_map::DefaultHasher::new();
883 let walker = ignore::WalkBuilder::new(root)
884 .standard_filters(true)
885 .hidden(true)
886 .git_ignore(true)
887 .build();
888 for entry in walker.flatten() {
889 if !entry.path().is_file() {
890 continue;
891 }
892 let Ok(meta) = entry.metadata() else { continue };
893 let mtime = meta
894 .modified()
895 .ok()
896 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
897 .map(|d| d.as_secs())
898 .unwrap_or(0);
899 entry.path().to_string_lossy().hash(&mut hasher);
900 mtime.hash(&mut hasher);
901 meta.len().hash(&mut hasher);
902 }
903 format!("local-{:016x}", hasher.finish())
904}
905
906fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
907 let out = Command::new("git")
908 .args(["rev-parse", refspec])
909 .current_dir(repo_path)
910 .output()
911 .context("git rev-parse failed")?;
912 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
913}
914
915fn now_iso() -> String {
916 format_iso(SystemTime::now())
917}
918
919fn format_iso(t: SystemTime) -> String {
920 let secs = t
921 .duration_since(SystemTime::UNIX_EPOCH)
922 .map(|d| d.as_secs())
923 .unwrap_or(0);
924 chrono_lite::format_secs(secs)
926}
927
928fn parse_iso(s: &str) -> Option<SystemTime> {
929 let secs = chrono_lite::parse_secs(s)?;
930 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
931}
932
933fn relative_time(iso: &str) -> String {
934 let Some(t) = parse_iso(iso) else {
935 return "unknown".to_string();
936 };
937 let now = SystemTime::now();
938 let delta = now.duration_since(t).unwrap_or_default().as_secs();
939 if delta < 3600 {
940 "just now".to_string()
941 } else if delta < 86_400 {
942 format!("{}h ago", delta / 3600)
943 } else {
944 format!("{}d ago", delta / 86_400)
945 }
946}
947
948mod chrono_lite {
951 pub fn format_secs(secs: u64) -> String {
952 let days = (secs / 86_400) as i64;
954 let time = secs % 86_400;
955 let (y, mo, d) = days_to_civil(days + 719_468);
956 let h = time / 3600;
957 let m = (time / 60) % 60;
958 let s = time % 60;
959 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
960 }
961
962 pub fn parse_secs(s: &str) -> Option<u64> {
963 let bytes = s.as_bytes();
966 if bytes.len() < 19 {
967 return None;
968 }
969 let y: i64 = s.get(0..4)?.parse().ok()?;
970 let mo: u32 = s.get(5..7)?.parse().ok()?;
971 let d: u32 = s.get(8..10)?.parse().ok()?;
972 let h: u64 = s.get(11..13)?.parse().ok()?;
973 let m: u64 = s.get(14..16)?.parse().ok()?;
974 let sc: u64 = s.get(17..19)?.parse().ok()?;
975 let days = civil_to_days(y, mo, d) - 719_468;
976 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
977 }
978
979 fn days_to_civil(z: i64) -> (i64, u32, u32) {
980 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
981 let doe = (z - era * 146_097) as u64;
982 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
983 let y = (yoe as i64) + era * 400;
984 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
985 let mp = (5 * doy + 2) / 153;
986 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
987 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
988 let y = if m <= 2 { y + 1 } else { y };
989 (y, m, d)
990 }
991
992 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
993 let y = if m <= 2 { y - 1 } else { y };
994 let era = if y >= 0 { y } else { y - 399 } / 400;
995 let yoe = (y - era * 400) as u64;
996 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
997 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
998 era * 146_097 + doe as i64
999 }
1000}
1001
1002#[allow(dead_code)]
1004fn _json_keepalive() {
1005 let _ = json!({});
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011
1012 #[test]
1013 fn validates_repo_names() {
1014 assert!(validate_repo_name("pydata/xarray").is_ok());
1015 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1016 assert!(validate_repo_name("xarray").is_err());
1017 assert!(validate_repo_name("a/b/c").is_err());
1018 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1019 }
1020
1021 #[test]
1022 fn open_creates_layout() {
1023 let dir = tempfile::tempdir().unwrap();
1024 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1025 assert!(ws.repos_dir().is_dir());
1026 }
1027
1028 #[test]
1029 fn empty_list() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1032 let out = ws.repo_management(None, false, false, false);
1033 assert!(out.contains("No repos cloned yet"));
1034 }
1035
1036 #[test]
1037 fn invalid_repo_name_rejected() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1040 let out = ws.repo_management(Some("bad name with spaces"), false, false, false);
1041 assert!(out.contains("Invalid repo name"));
1042 }
1043
1044 #[test]
1045 fn delete_unknown() {
1046 let dir = tempfile::tempdir().unwrap();
1047 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1048 let out = ws.repo_management(Some("nope/none"), true, false, false);
1049 assert!(out.contains("Nothing to delete"));
1050 }
1051
1052 #[test]
1053 fn iso_round_trip() {
1054 let now = SystemTime::now()
1055 .duration_since(SystemTime::UNIX_EPOCH)
1056 .unwrap()
1057 .as_secs();
1058 let s = chrono_lite::format_secs(now);
1059 let back = chrono_lite::parse_secs(&s).unwrap();
1060 assert_eq!(now, back);
1061 }
1062
1063 #[test]
1064 fn last_built_sha_round_trip() {
1065 let dir = tempfile::tempdir().unwrap();
1066 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1067 ws.bump_access("acme/widgets", "cloned");
1069 assert_eq!(ws.last_built_sha("acme/widgets"), None);
1070 ws.record_built_sha("acme/widgets", "abc1234deadbeef");
1071 assert_eq!(
1072 ws.last_built_sha("acme/widgets").as_deref(),
1073 Some("abc1234deadbeef")
1074 );
1075 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1077 assert_eq!(
1078 ws2.last_built_sha("acme/widgets").as_deref(),
1079 Some("abc1234deadbeef")
1080 );
1081 }
1082
1083 #[test]
1084 fn inventory_loads_legacy_entries_without_sha_field() {
1085 let dir = tempfile::tempdir().unwrap();
1086 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1087 let legacy = r#"{
1089 "old/repo": {
1090 "cloned_at": "2024-01-01T00:00:00",
1091 "last_accessed": "2024-01-01T00:00:00",
1092 "access_count": 5,
1093 "stale": false
1094 }
1095 }"#;
1096 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1097 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1099 assert_eq!(ws2.last_built_sha("old/repo"), None);
1100 let _ = ws;
1101 }
1102
1103 #[test]
1104 fn auto_rebuild_gate_skips_when_sha_matches() {
1105 use std::sync::atomic::{AtomicUsize, Ordering};
1106 let dir = tempfile::tempdir().unwrap();
1107 let calls = Arc::new(AtomicUsize::new(0));
1108 let calls_h = calls.clone();
1109 let hook: PostActivateHook = Arc::new(move |_path, _name| {
1110 calls_h.fetch_add(1, Ordering::SeqCst);
1111 Ok(())
1112 });
1113 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1119 ws.bump_access("acme/widgets", "cloned");
1121 ws.record_built_sha("acme/widgets", "sha_one");
1122 assert_eq!(
1123 ws.last_built_sha("acme/widgets").as_deref(),
1124 Some("sha_one")
1125 );
1126 ws.record_built_sha("acme/widgets", "sha_one");
1129 assert_eq!(
1130 ws.last_built_sha("acme/widgets").as_deref(),
1131 Some("sha_one")
1132 );
1133 assert_eq!(calls.load(Ordering::SeqCst), 0);
1136 }
1137
1138 #[test]
1139 fn local_workspace_binds_root_immediately() {
1140 let dir = tempfile::tempdir().unwrap();
1141 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1142 assert_eq!(ws.kind(), WorkspaceKind::Local);
1143 assert!(ws.active_repo_path().is_some());
1144 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1145 }
1146
1147 #[test]
1148 fn local_workspace_rejects_github_ops() {
1149 let dir = tempfile::tempdir().unwrap();
1150 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1151 let out = ws.repo_management(Some("acme/widgets"), false, false, false);
1152 assert!(out.contains("does not accept a repo name"));
1153 let out = ws.repo_management(None, true, false, false);
1154 assert!(out.contains("does not support `delete`"));
1155 }
1156
1157 #[test]
1158 fn local_workspace_update_rebuilds() {
1159 use std::sync::atomic::{AtomicUsize, Ordering};
1160 let dir = tempfile::tempdir().unwrap();
1161 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1163 let calls = Arc::new(AtomicUsize::new(0));
1164 let calls_h = calls.clone();
1165 let hook: PostActivateHook = Arc::new(move |_p, _n| {
1166 calls_h.fetch_add(1, Ordering::SeqCst);
1167 Ok(())
1168 });
1169 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1170 let _ = ws.repo_management(None, false, true, false);
1172 assert_eq!(calls.load(Ordering::SeqCst), 1);
1173 let out = ws.repo_management(None, false, true, false);
1175 assert_eq!(
1176 calls.load(Ordering::SeqCst),
1177 1,
1178 "auto-rebuild gate must skip"
1179 );
1180 assert!(out.contains("build skipped"));
1181 }
1182
1183 #[test]
1184 fn parses_github_remote_forms() {
1185 assert_eq!(
1186 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1187 Some("kkollsga/kglite")
1188 );
1189 assert_eq!(
1190 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1191 Some("kkollsga/kglite")
1192 );
1193 assert_eq!(
1195 parse_github_remote("https://github.com/acme/widget/").as_deref(),
1196 Some("acme/widget")
1197 );
1198 assert_eq!(
1199 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1200 Some("acme/widget")
1201 );
1202 assert_eq!(
1204 parse_github_remote("https://gitlab.com/acme/widget.git"),
1205 None
1206 );
1207 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1208 assert_eq!(parse_github_remote("not a url"), None);
1209 }
1210
1211 #[test]
1212 fn local_default_github_repo_uses_origin_remote() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let root = dir.path();
1215 let git = |args: &[&str]| {
1218 Command::new("git")
1219 .arg("-C")
1220 .arg(root)
1221 .args(args)
1222 .output()
1223 .unwrap()
1224 };
1225 if !git(&["init"]).status.success() {
1226 return;
1228 }
1229 git(&[
1230 "remote",
1231 "add",
1232 "origin",
1233 "https://github.com/acme/widget.git",
1234 ]);
1235 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1236 assert_eq!(
1237 ws.default_github_repo().as_deref(),
1238 Some("acme/widget"),
1239 "local default repo must come from the origin remote, not the inventory key"
1240 );
1241 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1243 }
1244
1245 #[test]
1246 fn local_default_github_repo_none_without_remote() {
1247 let dir = tempfile::tempdir().unwrap();
1248 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1249 let def = ws.default_github_repo();
1251 assert!(
1252 def.is_none(),
1253 "expected None for a non-git local root, got {def:?}"
1254 );
1255 }
1256
1257 #[test]
1258 fn set_root_dir_only_in_local_mode() {
1259 let dir = tempfile::tempdir().unwrap();
1260 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1261 let out = ws.set_root_dir(dir.path());
1262 assert!(out.contains("only valid in local-workspace"));
1263 }
1264
1265 #[test]
1266 fn update_with_no_active_repo() {
1267 let dir = tempfile::tempdir().unwrap();
1268 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1269 let out = ws.repo_management(None, false, true, false);
1270 assert!(out.contains("No active repository"));
1271 }
1272
1273 #[test]
1274 fn set_root_dir_updates_active_path() {
1275 let dir = tempfile::tempdir().unwrap();
1276 let child = dir.path().join("child");
1277 std::fs::create_dir_all(&child).unwrap();
1278 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1279 let _ = ws.set_root_dir(&child);
1280 assert_eq!(
1281 ws.active_repo_path().unwrap(),
1282 child.canonicalize().unwrap(),
1283 "set_root_dir didn't update active_repo_path"
1284 );
1285 }
1286
1287 #[test]
1288 fn set_root_dir_post_activate_fires_against_new_root() {
1289 let dir = tempfile::tempdir().unwrap();
1290 let child = dir.path().join("child");
1291 std::fs::create_dir_all(&child).unwrap();
1292 std::fs::write(child.join("a.txt"), b"hi").unwrap();
1293 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1294 let seen = seen_path.clone();
1295 let hook: PostActivateHook = Arc::new(move |p, _n| {
1296 *seen.lock().unwrap() = Some(p.to_path_buf());
1297 Ok(())
1298 });
1299 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1300 let _ = ws.set_root_dir(&child);
1301 assert_eq!(
1302 seen_path.lock().unwrap().clone().unwrap(),
1303 child.canonicalize().unwrap(),
1304 "post_activate hook saw the wrong root after set_root_dir"
1305 );
1306 }
1307
1308 #[test]
1309 fn activation_summary_appended_to_activate_message() {
1310 let dir = tempfile::tempdir().unwrap();
1311 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1312 let summary: ActivationSummaryHook =
1313 Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
1314 let ws = Workspace::open_local(dir.path().to_path_buf(), None)
1315 .unwrap()
1316 .with_activation_summary(summary);
1317 let out = ws.repo_management(None, false, true, false);
1318 assert!(
1319 out.contains("Graph ready: 3 Functions."),
1320 "activation message should include the summary; got: {out}"
1321 );
1322 }
1323
1324 #[test]
1325 fn activation_summary_absent_when_not_configured() {
1326 let dir = tempfile::tempdir().unwrap();
1327 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1328 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1329 let out = ws.repo_management(None, false, true, false);
1330 assert!(!out.contains("Graph ready"));
1331 assert!(
1332 out.contains(" at "),
1333 "expected the terse default message; got: {out}"
1334 );
1335 }
1336
1337 #[test]
1338 fn hook_fires_once_per_process_even_when_sha_matches() {
1339 use std::sync::atomic::{AtomicUsize, Ordering};
1340 let dir = tempfile::tempdir().unwrap();
1344 std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1345
1346 let calls = Arc::new(AtomicUsize::new(0));
1347 let make_hook = || -> PostActivateHook {
1348 let c = calls.clone();
1349 Arc::new(move |_p, _n| {
1350 c.fetch_add(1, Ordering::SeqCst);
1351 Ok(())
1352 })
1353 };
1354
1355 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1357 let _ = ws.repo_management(None, false, true, false);
1359 assert_eq!(
1360 calls.load(Ordering::SeqCst),
1361 1,
1362 "first activate must hydrate"
1363 );
1364 let out = ws.repo_management(None, false, true, false);
1366 assert_eq!(
1367 calls.load(Ordering::SeqCst),
1368 1,
1369 "repeat activate in same process must skip the hook"
1370 );
1371 assert!(
1372 out.contains("build skipped"),
1373 "expected skip suffix, got: {out}"
1374 );
1375 drop(ws);
1376
1377 let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1382 assert!(
1383 ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1384 .is_some(),
1385 "sanity: last_built_sha should survive the restart"
1386 );
1387 let _ = ws2.repo_management(None, false, true, false);
1388 assert_eq!(
1389 calls.load(Ordering::SeqCst),
1390 2,
1391 "fresh process must re-fire the hook even when the SHA matches"
1392 );
1393 }
1394}