1#![allow(dead_code)]
26
27use std::collections::BTreeMap;
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 active_built_name: Option<String>,
139}
140
141impl Workspace {
142 pub fn open(
144 workspace_dir: PathBuf,
145 stale_after_days: u32,
146 post_activate: Option<PostActivateHook>,
147 ) -> Result<Self> {
148 if !workspace_dir.is_dir() {
149 fs::create_dir_all(&workspace_dir).with_context(|| {
150 format!("failed to create workspace dir {}", workspace_dir.display())
151 })?;
152 }
153 let repos_dir = workspace_dir.join("repos");
154 if !repos_dir.is_dir() {
155 fs::create_dir_all(&repos_dir)
156 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
157 }
158 let ws = Self {
159 inner: Arc::new(WorkspaceInner {
160 kind: WorkspaceKind::Github,
161 workspace_dir,
162 stale_after_days,
163 state: RwLock::new(WorkspaceState::default()),
164 post_activate,
165 activation_summary: None,
166 }),
167 };
168 ws.reconcile_inventory()?;
169 Ok(ws)
170 }
171
172 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
180 if !root.is_dir() {
181 anyhow::bail!(
182 "local workspace root does not exist or is not a directory: {}",
183 root.display()
184 );
185 }
186 let canon_root = root
187 .canonicalize()
188 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
189 let inv_dir = canon_root.join(".mcp-workspace");
192 if !inv_dir.is_dir() {
193 fs::create_dir_all(&inv_dir).with_context(|| {
194 format!("failed to create local-workspace dir {}", inv_dir.display())
195 })?;
196 }
197 let mut state = WorkspaceState::default();
198 let synthetic_name = synthesize_local_name(&canon_root);
199 state.active_repo_name = Some(synthetic_name);
200 state.active_repo_path = Some(canon_root.clone());
201 Ok(Self {
202 inner: Arc::new(WorkspaceInner {
203 kind: WorkspaceKind::Local,
204 workspace_dir: canon_root,
205 stale_after_days: u32::MAX, state: RwLock::new(state),
207 post_activate,
208 activation_summary: None,
209 }),
210 })
211 }
212
213 pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
219 match Arc::get_mut(&mut self.inner) {
220 Some(inner) => inner.activation_summary = Some(hook),
221 None => tracing::warn!(
222 "with_activation_summary called after the workspace was cloned; summary not attached"
223 ),
224 }
225 self
226 }
227
228 pub fn kind(&self) -> WorkspaceKind {
229 self.inner.kind
230 }
231
232 pub fn workspace_dir(&self) -> &Path {
233 &self.inner.workspace_dir
234 }
235
236 pub fn repos_dir(&self) -> PathBuf {
237 self.inner.workspace_dir.join("repos")
238 }
239
240 fn inventory_path(&self) -> PathBuf {
241 match self.inner.kind {
242 WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
243 WorkspaceKind::Local => self
244 .inner
245 .workspace_dir
246 .join(".mcp-workspace")
247 .join("inventory.json"),
248 }
249 }
250
251 pub fn active_repo_name(&self) -> Option<String> {
253 self.inner.state.read().unwrap().active_repo_name.clone()
254 }
255
256 pub fn active_repo_path(&self) -> Option<PathBuf> {
258 self.inner.state.read().unwrap().active_repo_path.clone()
259 }
260
261 pub fn default_github_repo(&self) -> Option<String> {
270 match self.inner.kind {
271 WorkspaceKind::Github => self.active_repo_name(),
272 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
273 }
274 }
275
276 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
281 let path = self.inventory_path();
282 let Ok(text) = fs::read_to_string(&path) else {
283 return BTreeMap::new();
284 };
285 serde_json::from_str(&text).unwrap_or_default()
286 }
287
288 fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
289 let path = self.inventory_path();
290 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
291 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
292 Ok(())
293 }
294
295 fn reconcile_inventory(&self) -> Result<()> {
296 let mut inv = self.load_inventory();
297 let mut on_disk: Vec<String> = Vec::new();
298 if self.repos_dir().is_dir() {
299 for org_entry in fs::read_dir(self.repos_dir())? {
300 let Ok(org_entry) = org_entry else { continue };
301 if !org_entry.path().is_dir() {
302 continue;
303 }
304 let org = org_entry.file_name().to_string_lossy().into_owned();
305 if org.starts_with('.') {
306 continue;
307 }
308 for repo_entry in fs::read_dir(org_entry.path())? {
309 let Ok(repo_entry) = repo_entry else { continue };
310 if !repo_entry.path().is_dir() {
311 continue;
312 }
313 let repo = repo_entry.file_name().to_string_lossy().into_owned();
314 if repo.starts_with('.') {
315 continue;
316 }
317 let rname = format!("{org}/{repo}");
318 on_disk.push(rname.clone());
319 inv.entry(rname).or_insert_with(|| {
320 let mtime = repo_entry
321 .metadata()
322 .ok()
323 .and_then(|m| m.modified().ok())
324 .map(format_iso)
325 .unwrap_or_else(now_iso);
326 InventoryEntry {
327 cloned_at: mtime.clone(),
328 last_accessed: mtime,
329 access_count: 0,
330 stale: false,
331 last_built_sha: None,
332 }
333 });
334 }
335 }
336 }
337 for (rname, entry) in inv.iter_mut() {
338 if !on_disk.contains(rname) && !entry.stale {
339 entry.stale = true;
340 }
341 }
342 self.save_inventory(&inv)?;
343 Ok(())
344 }
345
346 fn bump_access(&self, name: &str, action: &str) {
347 let mut inv = self.load_inventory();
348 let now = now_iso();
349 let entry = inv
350 .entry(name.to_string())
351 .or_insert_with(|| InventoryEntry {
352 cloned_at: now.clone(),
353 last_accessed: now.clone(),
354 access_count: 0,
355 stale: false,
356 last_built_sha: None,
357 });
358 entry.last_accessed = now.clone();
359 entry.access_count += 1;
360 entry.stale = false;
361 if action == "cloned" || entry.cloned_at.is_empty() {
362 entry.cloned_at = now;
363 }
364 let _ = self.save_inventory(&inv);
365 }
366
367 fn mark_stale(&self, name: &str) {
368 let mut inv = self.load_inventory();
369 if let Some(entry) = inv.get_mut(name) {
370 entry.stale = true;
371 let _ = self.save_inventory(&inv);
372 }
373 }
374
375 fn sweep_stale(&self) -> Vec<String> {
376 if matches!(self.inner.kind, WorkspaceKind::Local) {
378 return Vec::new();
379 }
380 let mut inv = self.load_inventory();
381 let cutoff = SystemTime::now()
382 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
383 let active = self.active_repo_name();
384 let mut swept: Vec<String> = Vec::new();
385 for (rname, entry) in inv.iter_mut() {
386 if entry.stale {
387 continue;
388 }
389 if Some(rname.as_str()) == active.as_deref() {
390 continue;
391 }
392 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
393 if last >= cutoff {
394 continue;
395 }
396 let parts: Vec<&str> = rname.splitn(2, '/').collect();
397 if parts.len() != 2 {
398 continue;
399 }
400 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
401 if repo_path.exists() {
402 let _ = fs::remove_dir_all(&repo_path);
403 }
404 entry.stale = true;
405 swept.push(rname.clone());
406 }
407 if !swept.is_empty() {
408 let _ = self.save_inventory(&inv);
409 self.prune_empty_org_dirs();
410 }
411 swept
412 }
413
414 fn prune_empty_org_dirs(&self) {
415 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
416 return;
417 };
418 for entry in entries.flatten() {
419 let path = entry.path();
420 if !path.is_dir() {
421 continue;
422 }
423 if let Ok(children) = fs::read_dir(&path) {
424 let real: Vec<_> = children
425 .flatten()
426 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
427 .collect();
428 if real.is_empty() {
429 let _ = fs::remove_dir_all(&path);
430 }
431 }
432 }
433 }
434
435 fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
446 if matches!(self.inner.kind, WorkspaceKind::Local) {
447 let root = self
457 .inner
458 .state
459 .read()
460 .unwrap()
461 .active_repo_path
462 .clone()
463 .unwrap_or_else(|| self.inner.workspace_dir.clone());
464 let prev_sha = self.last_built_sha(name);
465 let fingerprint = fingerprint_dir(&root);
466 let action = match prev_sha {
467 Some(p) if p == fingerprint => "current",
468 None => "cloned", Some(_) => "updated",
470 };
471 return Ok((action.to_string(), root, fingerprint));
472 }
473 let parts: Vec<&str> = name.splitn(2, '/').collect();
474 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
475 if !repo_path.exists() {
476 fs::create_dir_all(repo_path.parent().unwrap()).ok();
477 let url = format!("https://github.com/{name}.git");
478 let out = Command::new("git")
479 .args(["clone", "--depth", "1", &url, repo_path.to_str().unwrap()])
480 .output()
481 .context("failed to spawn `git clone`")?;
482 if !out.status.success() {
483 anyhow::bail!(
484 "git clone failed: {}",
485 String::from_utf8_lossy(&out.stderr).trim()
486 );
487 }
488 let sha = git_rev_parse(&repo_path, "HEAD")?;
489 return Ok(("cloned".to_string(), repo_path, sha));
490 }
491
492 Command::new("git")
494 .args(["fetch", "--depth", "1", "origin"])
495 .current_dir(&repo_path)
496 .output()
497 .context("git fetch failed")?;
498 let local = git_rev_parse(&repo_path, "HEAD")?;
499 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
500 if local != remote {
501 Command::new("git")
502 .args(["reset", "--hard", "FETCH_HEAD"])
503 .current_dir(&repo_path)
504 .output()
505 .context("git reset failed")?;
506 let sha = git_rev_parse(&repo_path, "HEAD")?;
507 return Ok(("updated".to_string(), repo_path, sha));
508 }
509 Ok(("current".to_string(), repo_path, local))
510 }
511
512 fn activate(&self, name: &str, force_rebuild: bool) -> Result<String> {
525 let prev_built_sha = self.last_built_sha(name);
526 let (action, repo_path, head_sha) = self.clone_or_update(name)?;
527 self.bump_access(name, &action);
528 let is_active_built = {
529 let mut state = self.inner.state.write().unwrap();
530 state.active_repo_name = Some(name.to_string());
531 state.active_repo_path = Some(repo_path.clone());
532 state.active_built_name.as_deref() == Some(name)
533 };
534
535 let already_built = !force_rebuild
547 && action == "current"
548 && prev_built_sha.as_deref() == Some(head_sha.as_str())
549 && is_active_built;
550 let mut hook_skipped = false;
551 let hook_ok = if already_built {
552 hook_skipped = true;
553 true
554 } else if let Some(hook) = &self.inner.post_activate {
555 match hook(&repo_path, name) {
556 Ok(()) => true,
557 Err(e) => {
558 tracing::warn!("post-activate hook for {name} failed: {e}");
559 false
560 }
561 }
562 } else {
563 true
566 };
567 if hook_ok {
568 self.record_built_sha(name, &head_sha);
569 }
570 if hook_ok && !hook_skipped {
578 self.inner.state.write().unwrap().active_built_name = Some(name.to_string());
579 }
580 let verb = match action.as_str() {
581 "cloned" => "Cloned",
582 "updated" => "Updated",
583 "current" => "Activated (already up to date)",
584 other => other,
585 };
586 let suffix = if hook_skipped {
587 " [build skipped: HEAD matches last-built SHA]"
588 } else {
589 ""
590 };
591 let base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
592 let summary = if hook_ok {
598 self.inner
599 .activation_summary
600 .as_ref()
601 .and_then(|h| h(&repo_path, name))
602 } else {
603 None
604 };
605 Ok(match summary {
606 Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
607 _ => base,
608 })
609 }
610
611 fn record_built_sha(&self, name: &str, sha: &str) {
612 let mut inv = self.load_inventory();
613 if let Some(entry) = inv.get_mut(name) {
614 entry.last_built_sha = Some(sha.to_string());
615 let _ = self.save_inventory(&inv);
616 }
617 }
618
619 pub fn last_built_sha(&self, name: &str) -> Option<String> {
624 self.load_inventory()
625 .get(name)
626 .and_then(|e| e.last_built_sha.clone())
627 }
628
629 fn delete(&self, name: &str) -> Result<String> {
630 let parts: Vec<&str> = name.splitn(2, '/').collect();
631 if parts.len() != 2 {
632 anyhow::bail!("Invalid repo name");
633 }
634 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
635 let mut deleted = Vec::new();
636 if repo_path.exists() {
637 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
638 deleted.push("repo");
639 }
640 self.mark_stale(name);
641 self.prune_empty_org_dirs();
642 if deleted.is_empty() {
643 return Ok(format!("Nothing to delete — '{name}' not found."));
644 }
645 let mut state = self.inner.state.write().unwrap();
646 if state.active_repo_name.as_deref() == Some(name) {
647 state.active_repo_name = None;
648 state.active_repo_path = None;
649 return Ok(format!(
650 "Deleted {}. Active repo cleared.",
651 deleted.join(", ")
652 ));
653 }
654 Ok(format!("Deleted {}.", deleted.join(", ")))
655 }
656
657 fn list(&self) -> String {
658 let inv = self.load_inventory();
659 if inv.is_empty() {
660 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
661 .to_string();
662 }
663 let active = self.active_repo_name();
664 let mut live: Vec<String> = Vec::new();
665 let mut stale_lines: Vec<String> = Vec::new();
666 for (rname, entry) in &inv {
667 let marker = if Some(rname.as_str()) == active.as_deref() {
668 " [active]"
669 } else {
670 ""
671 };
672 let access = format!(
673 "{} access{}, last {}",
674 entry.access_count,
675 if entry.access_count == 1 { "" } else { "es" },
676 relative_time(&entry.last_accessed)
677 );
678 if entry.stale {
679 stale_lines.push(format!(
680 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
681 ));
682 } else {
683 live.push(format!(" {rname}{marker} ({access})"));
684 }
685 }
686 let mut out = String::new();
687 if !live.is_empty() {
688 out.push_str(&format!(
689 "{} live repo(s):\n{}",
690 live.len(),
691 live.join("\n")
692 ));
693 }
694 if !stale_lines.is_empty() {
695 if !out.is_empty() {
696 out.push_str("\n\n");
697 }
698 out.push_str(&format!(
699 "{} stale repo(s):\n{}",
700 stale_lines.len(),
701 stale_lines.join("\n")
702 ));
703 }
704 out
705 }
706
707 pub fn repo_management(
721 &self,
722 name: Option<&str>,
723 delete: bool,
724 update: bool,
725 force_rebuild: bool,
726 ) -> String {
727 if matches!(self.inner.kind, WorkspaceKind::Local) {
729 if name.is_some() {
730 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
731 to switch the active root, or pass `update=true` / `force_rebuild=true` \
732 to rebuild against the current root."
733 .to_string();
734 }
735 if delete {
736 return "Local-workspace mode does not support `delete`. The root is owned by the \
737 operator; remove it manually."
738 .to_string();
739 }
740 let active = match self.active_repo_name() {
741 Some(n) => n,
742 None => return "No active local root.".to_string(),
743 };
744 let _ = update; return self
751 .activate(&active, force_rebuild)
752 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
753 }
754
755 let swept = self.sweep_stale();
756 let prefix = if swept.is_empty() {
757 String::new()
758 } else {
759 format!(
760 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
761 swept.len(),
762 self.inner.stale_after_days,
763 swept.join(", ")
764 )
765 };
766
767 if name.is_none() && !update {
768 return prefix + &self.list();
769 }
770
771 if update {
772 let Some(active) = self.active_repo_name() else {
773 return prefix + "No active repository. Call repo_management('org/repo') first.";
774 };
775 return prefix
776 + &self
777 .activate(&active, force_rebuild)
778 .unwrap_or_else(|e| format!("update failed: {e}"));
779 }
780
781 let Some(name) = name else {
782 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
783 };
784 if let Err(e) = validate_repo_name(name) {
785 return prefix + &e.to_string();
786 }
787 if delete {
788 return prefix
789 + &self
790 .delete(name)
791 .unwrap_or_else(|e| format!("delete failed: {e}"));
792 }
793 prefix
794 + &self
795 .activate(name, force_rebuild)
796 .unwrap_or_else(|e| format!("activate failed: {e}"))
797 }
798
799 pub fn set_root_dir(&self, new_root: &Path) -> String {
802 if !matches!(self.inner.kind, WorkspaceKind::Local) {
803 return "set_root_dir is only valid in local-workspace mode.".to_string();
804 }
805 if !new_root.is_dir() {
806 return format!(
807 "Path does not exist or is not a directory: {}",
808 new_root.display()
809 );
810 }
811 let canon = match new_root.canonicalize() {
812 Ok(p) => p,
813 Err(e) => return format!("canonicalize failed: {e}"),
814 };
815 let synthetic = synthesize_local_name(&canon);
816 {
817 let mut state = self.inner.state.write().unwrap();
818 state.active_repo_name = Some(synthetic.clone());
819 state.active_repo_path = Some(canon.clone());
820 }
821 self.activate(&synthetic, false)
825 .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
826 }
827}
828
829fn synthesize_local_name(root: &Path) -> String {
833 let name = root
834 .file_name()
835 .map(|s| s.to_string_lossy().into_owned())
836 .unwrap_or_else(|| "local".to_string());
837 format!("local/{name}")
838}
839
840fn parse_origin_repo(root: &Path) -> Option<String> {
851 let out = Command::new("git")
852 .arg("-C")
853 .arg(root)
854 .args(["remote", "get-url", "origin"])
855 .output()
856 .ok()?;
857 if !out.status.success() {
858 return None;
859 }
860 let url = String::from_utf8(out.stdout).ok()?;
861 parse_github_remote(url.trim())
862}
863
864fn parse_github_remote(url: &str) -> Option<String> {
867 let path = url
871 .strip_prefix("git@github.com:")
872 .or_else(|| url.strip_prefix("https://github.com/"))
873 .or_else(|| url.strip_prefix("http://github.com/"))
874 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
875 let path = path.strip_suffix(".git").unwrap_or(path);
876 let path = path.trim_end_matches('/');
877 let mut parts = path.split('/');
879 let org = parts.next().filter(|s| !s.is_empty())?;
880 let repo = parts.next().filter(|s| !s.is_empty())?;
881 if parts.next().is_some() {
882 return None;
883 }
884 Some(format!("{org}/{repo}"))
885}
886
887fn fingerprint_dir(root: &Path) -> String {
892 use std::hash::{Hash, Hasher};
893 let mut hasher = std::collections::hash_map::DefaultHasher::new();
894 let walker = ignore::WalkBuilder::new(root)
895 .standard_filters(true)
896 .hidden(true)
897 .git_ignore(true)
898 .build();
899 for entry in walker.flatten() {
900 if !entry.path().is_file() {
901 continue;
902 }
903 let Ok(meta) = entry.metadata() else { continue };
904 let mtime = meta
905 .modified()
906 .ok()
907 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
908 .map(|d| d.as_secs())
909 .unwrap_or(0);
910 entry.path().to_string_lossy().hash(&mut hasher);
911 mtime.hash(&mut hasher);
912 meta.len().hash(&mut hasher);
913 }
914 format!("local-{:016x}", hasher.finish())
915}
916
917fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
918 let out = Command::new("git")
919 .args(["rev-parse", refspec])
920 .current_dir(repo_path)
921 .output()
922 .context("git rev-parse failed")?;
923 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
924}
925
926fn now_iso() -> String {
927 format_iso(SystemTime::now())
928}
929
930fn format_iso(t: SystemTime) -> String {
931 let secs = t
932 .duration_since(SystemTime::UNIX_EPOCH)
933 .map(|d| d.as_secs())
934 .unwrap_or(0);
935 chrono_lite::format_secs(secs)
937}
938
939fn parse_iso(s: &str) -> Option<SystemTime> {
940 let secs = chrono_lite::parse_secs(s)?;
941 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
942}
943
944fn relative_time(iso: &str) -> String {
945 let Some(t) = parse_iso(iso) else {
946 return "unknown".to_string();
947 };
948 let now = SystemTime::now();
949 let delta = now.duration_since(t).unwrap_or_default().as_secs();
950 if delta < 3600 {
951 "just now".to_string()
952 } else if delta < 86_400 {
953 format!("{}h ago", delta / 3600)
954 } else {
955 format!("{}d ago", delta / 86_400)
956 }
957}
958
959mod chrono_lite {
962 pub fn format_secs(secs: u64) -> String {
963 let days = (secs / 86_400) as i64;
965 let time = secs % 86_400;
966 let (y, mo, d) = days_to_civil(days + 719_468);
967 let h = time / 3600;
968 let m = (time / 60) % 60;
969 let s = time % 60;
970 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
971 }
972
973 pub fn parse_secs(s: &str) -> Option<u64> {
974 let bytes = s.as_bytes();
977 if bytes.len() < 19 {
978 return None;
979 }
980 let y: i64 = s.get(0..4)?.parse().ok()?;
981 let mo: u32 = s.get(5..7)?.parse().ok()?;
982 let d: u32 = s.get(8..10)?.parse().ok()?;
983 let h: u64 = s.get(11..13)?.parse().ok()?;
984 let m: u64 = s.get(14..16)?.parse().ok()?;
985 let sc: u64 = s.get(17..19)?.parse().ok()?;
986 let days = civil_to_days(y, mo, d) - 719_468;
987 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
988 }
989
990 fn days_to_civil(z: i64) -> (i64, u32, u32) {
991 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
992 let doe = (z - era * 146_097) as u64;
993 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
994 let y = (yoe as i64) + era * 400;
995 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
996 let mp = (5 * doy + 2) / 153;
997 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
998 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
999 let y = if m <= 2 { y + 1 } else { y };
1000 (y, m, d)
1001 }
1002
1003 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1004 let y = if m <= 2 { y - 1 } else { y };
1005 let era = if y >= 0 { y } else { y - 399 } / 400;
1006 let yoe = (y - era * 400) as u64;
1007 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1008 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1009 era * 146_097 + doe as i64
1010 }
1011}
1012
1013#[allow(dead_code)]
1015fn _json_keepalive() {
1016 let _ = json!({});
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022
1023 #[test]
1024 fn validates_repo_names() {
1025 assert!(validate_repo_name("pydata/xarray").is_ok());
1026 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1027 assert!(validate_repo_name("xarray").is_err());
1028 assert!(validate_repo_name("a/b/c").is_err());
1029 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1030 }
1031
1032 #[test]
1033 fn open_creates_layout() {
1034 let dir = tempfile::tempdir().unwrap();
1035 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1036 assert!(ws.repos_dir().is_dir());
1037 }
1038
1039 #[test]
1040 fn empty_list() {
1041 let dir = tempfile::tempdir().unwrap();
1042 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1043 let out = ws.repo_management(None, false, false, false);
1044 assert!(out.contains("No repos cloned yet"));
1045 }
1046
1047 #[test]
1048 fn invalid_repo_name_rejected() {
1049 let dir = tempfile::tempdir().unwrap();
1050 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1051 let out = ws.repo_management(Some("bad name with spaces"), false, false, false);
1052 assert!(out.contains("Invalid repo name"));
1053 }
1054
1055 #[test]
1056 fn delete_unknown() {
1057 let dir = tempfile::tempdir().unwrap();
1058 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1059 let out = ws.repo_management(Some("nope/none"), true, false, false);
1060 assert!(out.contains("Nothing to delete"));
1061 }
1062
1063 #[test]
1064 fn iso_round_trip() {
1065 let now = SystemTime::now()
1066 .duration_since(SystemTime::UNIX_EPOCH)
1067 .unwrap()
1068 .as_secs();
1069 let s = chrono_lite::format_secs(now);
1070 let back = chrono_lite::parse_secs(&s).unwrap();
1071 assert_eq!(now, back);
1072 }
1073
1074 #[test]
1075 fn last_built_sha_round_trip() {
1076 let dir = tempfile::tempdir().unwrap();
1077 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1078 ws.bump_access("acme/widgets", "cloned");
1080 assert_eq!(ws.last_built_sha("acme/widgets"), None);
1081 ws.record_built_sha("acme/widgets", "abc1234deadbeef");
1082 assert_eq!(
1083 ws.last_built_sha("acme/widgets").as_deref(),
1084 Some("abc1234deadbeef")
1085 );
1086 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1088 assert_eq!(
1089 ws2.last_built_sha("acme/widgets").as_deref(),
1090 Some("abc1234deadbeef")
1091 );
1092 }
1093
1094 #[test]
1095 fn inventory_loads_legacy_entries_without_sha_field() {
1096 let dir = tempfile::tempdir().unwrap();
1097 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1098 let legacy = r#"{
1100 "old/repo": {
1101 "cloned_at": "2024-01-01T00:00:00",
1102 "last_accessed": "2024-01-01T00:00:00",
1103 "access_count": 5,
1104 "stale": false
1105 }
1106 }"#;
1107 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1108 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1110 assert_eq!(ws2.last_built_sha("old/repo"), None);
1111 let _ = ws;
1112 }
1113
1114 #[test]
1115 fn auto_rebuild_gate_skips_when_sha_matches() {
1116 use std::sync::atomic::{AtomicUsize, Ordering};
1117 let dir = tempfile::tempdir().unwrap();
1118 let calls = Arc::new(AtomicUsize::new(0));
1119 let calls_h = calls.clone();
1120 let hook: PostActivateHook = Arc::new(move |_path, _name| {
1121 calls_h.fetch_add(1, Ordering::SeqCst);
1122 Ok(())
1123 });
1124 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1130 ws.bump_access("acme/widgets", "cloned");
1132 ws.record_built_sha("acme/widgets", "sha_one");
1133 assert_eq!(
1134 ws.last_built_sha("acme/widgets").as_deref(),
1135 Some("sha_one")
1136 );
1137 ws.record_built_sha("acme/widgets", "sha_one");
1140 assert_eq!(
1141 ws.last_built_sha("acme/widgets").as_deref(),
1142 Some("sha_one")
1143 );
1144 assert_eq!(calls.load(Ordering::SeqCst), 0);
1147 }
1148
1149 #[test]
1150 fn local_workspace_binds_root_immediately() {
1151 let dir = tempfile::tempdir().unwrap();
1152 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1153 assert_eq!(ws.kind(), WorkspaceKind::Local);
1154 assert!(ws.active_repo_path().is_some());
1155 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1156 }
1157
1158 #[test]
1159 fn local_workspace_rejects_github_ops() {
1160 let dir = tempfile::tempdir().unwrap();
1161 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1162 let out = ws.repo_management(Some("acme/widgets"), false, false, false);
1163 assert!(out.contains("does not accept a repo name"));
1164 let out = ws.repo_management(None, true, false, false);
1165 assert!(out.contains("does not support `delete`"));
1166 }
1167
1168 #[test]
1169 fn local_workspace_update_rebuilds() {
1170 use std::sync::atomic::{AtomicUsize, Ordering};
1171 let dir = tempfile::tempdir().unwrap();
1172 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1174 let calls = Arc::new(AtomicUsize::new(0));
1175 let calls_h = calls.clone();
1176 let hook: PostActivateHook = Arc::new(move |_p, _n| {
1177 calls_h.fetch_add(1, Ordering::SeqCst);
1178 Ok(())
1179 });
1180 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1181 let _ = ws.repo_management(None, false, true, false);
1183 assert_eq!(calls.load(Ordering::SeqCst), 1);
1184 let out = ws.repo_management(None, false, true, false);
1186 assert_eq!(
1187 calls.load(Ordering::SeqCst),
1188 1,
1189 "auto-rebuild gate must skip"
1190 );
1191 assert!(out.contains("build skipped"));
1192 }
1193
1194 #[test]
1195 fn parses_github_remote_forms() {
1196 assert_eq!(
1197 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1198 Some("kkollsga/kglite")
1199 );
1200 assert_eq!(
1201 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1202 Some("kkollsga/kglite")
1203 );
1204 assert_eq!(
1206 parse_github_remote("https://github.com/acme/widget/").as_deref(),
1207 Some("acme/widget")
1208 );
1209 assert_eq!(
1210 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1211 Some("acme/widget")
1212 );
1213 assert_eq!(
1215 parse_github_remote("https://gitlab.com/acme/widget.git"),
1216 None
1217 );
1218 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1219 assert_eq!(parse_github_remote("not a url"), None);
1220 }
1221
1222 #[test]
1223 fn local_default_github_repo_uses_origin_remote() {
1224 let dir = tempfile::tempdir().unwrap();
1225 let root = dir.path();
1226 let git = |args: &[&str]| {
1229 Command::new("git")
1230 .arg("-C")
1231 .arg(root)
1232 .args(args)
1233 .output()
1234 .unwrap()
1235 };
1236 if !git(&["init"]).status.success() {
1237 return;
1239 }
1240 git(&[
1241 "remote",
1242 "add",
1243 "origin",
1244 "https://github.com/acme/widget.git",
1245 ]);
1246 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1247 assert_eq!(
1248 ws.default_github_repo().as_deref(),
1249 Some("acme/widget"),
1250 "local default repo must come from the origin remote, not the inventory key"
1251 );
1252 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1254 }
1255
1256 #[test]
1257 fn local_default_github_repo_none_without_remote() {
1258 let dir = tempfile::tempdir().unwrap();
1259 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1260 let def = ws.default_github_repo();
1262 assert!(
1263 def.is_none(),
1264 "expected None for a non-git local root, got {def:?}"
1265 );
1266 }
1267
1268 #[test]
1269 fn set_root_dir_only_in_local_mode() {
1270 let dir = tempfile::tempdir().unwrap();
1271 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1272 let out = ws.set_root_dir(dir.path());
1273 assert!(out.contains("only valid in local-workspace"));
1274 }
1275
1276 #[test]
1277 fn update_with_no_active_repo() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1280 let out = ws.repo_management(None, false, true, false);
1281 assert!(out.contains("No active repository"));
1282 }
1283
1284 #[test]
1285 fn set_root_dir_updates_active_path() {
1286 let dir = tempfile::tempdir().unwrap();
1287 let child = dir.path().join("child");
1288 std::fs::create_dir_all(&child).unwrap();
1289 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1290 let _ = ws.set_root_dir(&child);
1291 assert_eq!(
1292 ws.active_repo_path().unwrap(),
1293 child.canonicalize().unwrap(),
1294 "set_root_dir didn't update active_repo_path"
1295 );
1296 }
1297
1298 #[test]
1299 fn set_root_dir_post_activate_fires_against_new_root() {
1300 let dir = tempfile::tempdir().unwrap();
1301 let child = dir.path().join("child");
1302 std::fs::create_dir_all(&child).unwrap();
1303 std::fs::write(child.join("a.txt"), b"hi").unwrap();
1304 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1305 let seen = seen_path.clone();
1306 let hook: PostActivateHook = Arc::new(move |p, _n| {
1307 *seen.lock().unwrap() = Some(p.to_path_buf());
1308 Ok(())
1309 });
1310 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1311 let _ = ws.set_root_dir(&child);
1312 assert_eq!(
1313 seen_path.lock().unwrap().clone().unwrap(),
1314 child.canonicalize().unwrap(),
1315 "post_activate hook saw the wrong root after set_root_dir"
1316 );
1317 }
1318
1319 #[test]
1320 fn activation_summary_appended_to_activate_message() {
1321 let dir = tempfile::tempdir().unwrap();
1322 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1323 let summary: ActivationSummaryHook =
1324 Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
1325 let ws = Workspace::open_local(dir.path().to_path_buf(), None)
1326 .unwrap()
1327 .with_activation_summary(summary);
1328 let out = ws.repo_management(None, false, true, false);
1329 assert!(
1330 out.contains("Graph ready: 3 Functions."),
1331 "activation message should include the summary; got: {out}"
1332 );
1333 }
1334
1335 #[test]
1336 fn activation_summary_absent_when_not_configured() {
1337 let dir = tempfile::tempdir().unwrap();
1338 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1339 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1340 let out = ws.repo_management(None, false, true, false);
1341 assert!(!out.contains("Graph ready"));
1342 assert!(
1343 out.contains(" at "),
1344 "expected the terse default message; got: {out}"
1345 );
1346 }
1347
1348 #[test]
1349 fn hook_fires_once_per_process_even_when_sha_matches() {
1350 use std::sync::atomic::{AtomicUsize, Ordering};
1351 let dir = tempfile::tempdir().unwrap();
1355 std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1356
1357 let calls = Arc::new(AtomicUsize::new(0));
1358 let make_hook = || -> PostActivateHook {
1359 let c = calls.clone();
1360 Arc::new(move |_p, _n| {
1361 c.fetch_add(1, Ordering::SeqCst);
1362 Ok(())
1363 })
1364 };
1365
1366 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1368 let _ = ws.repo_management(None, false, true, false);
1370 assert_eq!(
1371 calls.load(Ordering::SeqCst),
1372 1,
1373 "first activate must hydrate"
1374 );
1375 let out = ws.repo_management(None, false, true, false);
1377 assert_eq!(
1378 calls.load(Ordering::SeqCst),
1379 1,
1380 "repeat activate in same process must skip the hook"
1381 );
1382 assert!(
1383 out.contains("build skipped"),
1384 "expected skip suffix, got: {out}"
1385 );
1386 drop(ws);
1387
1388 let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1393 assert!(
1394 ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1395 .is_some(),
1396 "sanity: last_built_sha should survive the restart"
1397 );
1398 let _ = ws2.repo_management(None, false, true, false);
1399 assert_eq!(
1400 calls.load(Ordering::SeqCst),
1401 2,
1402 "fresh process must re-fire the hook even when the SHA matches"
1403 );
1404 }
1405
1406 #[test]
1407 fn a_b_a_swap_rebuilds_intervening_root() {
1408 use std::sync::atomic::{AtomicUsize, Ordering};
1414 let root = tempfile::tempdir().unwrap();
1415 let a = root.path().join("projA");
1416 let b = root.path().join("projB");
1417 std::fs::create_dir_all(&a).unwrap();
1418 std::fs::create_dir_all(&b).unwrap();
1419 std::fs::write(a.join("a.txt"), b"alpha").unwrap();
1423 std::fs::write(b.join("b.txt"), b"beta").unwrap();
1424
1425 let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1428 let built_h = built.clone();
1429 let calls = Arc::new(AtomicUsize::new(0));
1430 let calls_h = calls.clone();
1431 let hook: PostActivateHook = Arc::new(move |p, _n| {
1432 *built_h.lock().unwrap() = Some(p.to_path_buf());
1433 calls_h.fetch_add(1, Ordering::SeqCst);
1434 Ok(())
1435 });
1436
1437 let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
1438 let _ = ws.set_root_dir(&a);
1441 assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
1442 assert_eq!(
1443 built.lock().unwrap().clone(),
1444 Some(a.canonicalize().unwrap())
1445 );
1446
1447 let _ = ws.set_root_dir(&b);
1448 assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
1449 assert_eq!(
1450 built.lock().unwrap().clone(),
1451 Some(b.canonicalize().unwrap())
1452 );
1453
1454 let out = ws.set_root_dir(&a);
1457 assert_eq!(
1458 calls.load(Ordering::SeqCst),
1459 3,
1460 "A→B→A must rebuild A; the intervening B overwrote the live slot"
1461 );
1462 assert!(
1463 !out.contains("build skipped"),
1464 "re-bind of a non-active root must not skip; got: {out}"
1465 );
1466 assert_eq!(
1467 built.lock().unwrap().clone(),
1468 Some(a.canonicalize().unwrap()),
1469 "after A→B→A the live slot must hold A, not B"
1470 );
1471
1472 let out = ws.set_root_dir(&a);
1475 assert_eq!(
1476 calls.load(Ordering::SeqCst),
1477 3,
1478 "re-binding the already-active root must skip the hook"
1479 );
1480 assert!(
1481 out.contains("build skipped"),
1482 "expected skip suffix, got: {out}"
1483 );
1484 }
1485}