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
66#[derive(Debug, Clone, Serialize, Deserialize)]
68struct InventoryEntry {
69 cloned_at: String,
70 last_accessed: String,
71 #[serde(default)]
72 access_count: u64,
73 #[serde(default)]
74 stale: bool,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
81 last_built_sha: Option<String>,
82}
83
84pub use crate::server::manifest::WorkspaceKind;
87
88#[derive(Clone)]
90pub struct Workspace {
91 inner: Arc<WorkspaceInner>,
92}
93
94struct WorkspaceInner {
95 kind: WorkspaceKind,
96 workspace_dir: PathBuf,
97 stale_after_days: u32,
98 state: RwLock<WorkspaceState>,
99 post_activate: Option<PostActivateHook>,
100}
101
102#[derive(Debug, Default)]
103struct WorkspaceState {
104 active_repo_name: Option<String>,
105 active_repo_path: Option<PathBuf>,
106 hydrated_this_process: HashSet<String>,
114}
115
116impl Workspace {
117 pub fn open(
119 workspace_dir: PathBuf,
120 stale_after_days: u32,
121 post_activate: Option<PostActivateHook>,
122 ) -> Result<Self> {
123 if !workspace_dir.is_dir() {
124 fs::create_dir_all(&workspace_dir).with_context(|| {
125 format!("failed to create workspace dir {}", workspace_dir.display())
126 })?;
127 }
128 let repos_dir = workspace_dir.join("repos");
129 if !repos_dir.is_dir() {
130 fs::create_dir_all(&repos_dir)
131 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
132 }
133 let ws = Self {
134 inner: Arc::new(WorkspaceInner {
135 kind: WorkspaceKind::Github,
136 workspace_dir,
137 stale_after_days,
138 state: RwLock::new(WorkspaceState::default()),
139 post_activate,
140 }),
141 };
142 ws.reconcile_inventory()?;
143 Ok(ws)
144 }
145
146 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
154 if !root.is_dir() {
155 anyhow::bail!(
156 "local workspace root does not exist or is not a directory: {}",
157 root.display()
158 );
159 }
160 let canon_root = root
161 .canonicalize()
162 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
163 let inv_dir = canon_root.join(".mcp-workspace");
166 if !inv_dir.is_dir() {
167 fs::create_dir_all(&inv_dir).with_context(|| {
168 format!("failed to create local-workspace dir {}", inv_dir.display())
169 })?;
170 }
171 let mut state = WorkspaceState::default();
172 let synthetic_name = synthesize_local_name(&canon_root);
173 state.active_repo_name = Some(synthetic_name);
174 state.active_repo_path = Some(canon_root.clone());
175 Ok(Self {
176 inner: Arc::new(WorkspaceInner {
177 kind: WorkspaceKind::Local,
178 workspace_dir: canon_root,
179 stale_after_days: u32::MAX, state: RwLock::new(state),
181 post_activate,
182 }),
183 })
184 }
185
186 pub fn kind(&self) -> WorkspaceKind {
187 self.inner.kind
188 }
189
190 pub fn workspace_dir(&self) -> &Path {
191 &self.inner.workspace_dir
192 }
193
194 pub fn repos_dir(&self) -> PathBuf {
195 self.inner.workspace_dir.join("repos")
196 }
197
198 fn inventory_path(&self) -> PathBuf {
199 match self.inner.kind {
200 WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
201 WorkspaceKind::Local => self
202 .inner
203 .workspace_dir
204 .join(".mcp-workspace")
205 .join("inventory.json"),
206 }
207 }
208
209 pub fn active_repo_name(&self) -> Option<String> {
211 self.inner.state.read().unwrap().active_repo_name.clone()
212 }
213
214 pub fn active_repo_path(&self) -> Option<PathBuf> {
216 self.inner.state.read().unwrap().active_repo_path.clone()
217 }
218
219 pub fn default_github_repo(&self) -> Option<String> {
228 match self.inner.kind {
229 WorkspaceKind::Github => self.active_repo_name(),
230 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
231 }
232 }
233
234 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
239 let path = self.inventory_path();
240 let Ok(text) = fs::read_to_string(&path) else {
241 return BTreeMap::new();
242 };
243 serde_json::from_str(&text).unwrap_or_default()
244 }
245
246 fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
247 let path = self.inventory_path();
248 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
249 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
250 Ok(())
251 }
252
253 fn reconcile_inventory(&self) -> Result<()> {
254 let mut inv = self.load_inventory();
255 let mut on_disk: Vec<String> = Vec::new();
256 if self.repos_dir().is_dir() {
257 for org_entry in fs::read_dir(self.repos_dir())? {
258 let Ok(org_entry) = org_entry else { continue };
259 if !org_entry.path().is_dir() {
260 continue;
261 }
262 let org = org_entry.file_name().to_string_lossy().into_owned();
263 if org.starts_with('.') {
264 continue;
265 }
266 for repo_entry in fs::read_dir(org_entry.path())? {
267 let Ok(repo_entry) = repo_entry else { continue };
268 if !repo_entry.path().is_dir() {
269 continue;
270 }
271 let repo = repo_entry.file_name().to_string_lossy().into_owned();
272 if repo.starts_with('.') {
273 continue;
274 }
275 let rname = format!("{org}/{repo}");
276 on_disk.push(rname.clone());
277 inv.entry(rname).or_insert_with(|| {
278 let mtime = repo_entry
279 .metadata()
280 .ok()
281 .and_then(|m| m.modified().ok())
282 .map(format_iso)
283 .unwrap_or_else(now_iso);
284 InventoryEntry {
285 cloned_at: mtime.clone(),
286 last_accessed: mtime,
287 access_count: 0,
288 stale: false,
289 last_built_sha: None,
290 }
291 });
292 }
293 }
294 }
295 for (rname, entry) in inv.iter_mut() {
296 if !on_disk.contains(rname) && !entry.stale {
297 entry.stale = true;
298 }
299 }
300 self.save_inventory(&inv)?;
301 Ok(())
302 }
303
304 fn bump_access(&self, name: &str, action: &str) {
305 let mut inv = self.load_inventory();
306 let now = now_iso();
307 let entry = inv
308 .entry(name.to_string())
309 .or_insert_with(|| InventoryEntry {
310 cloned_at: now.clone(),
311 last_accessed: now.clone(),
312 access_count: 0,
313 stale: false,
314 last_built_sha: None,
315 });
316 entry.last_accessed = now.clone();
317 entry.access_count += 1;
318 entry.stale = false;
319 if action == "cloned" || entry.cloned_at.is_empty() {
320 entry.cloned_at = now;
321 }
322 let _ = self.save_inventory(&inv);
323 }
324
325 fn mark_stale(&self, name: &str) {
326 let mut inv = self.load_inventory();
327 if let Some(entry) = inv.get_mut(name) {
328 entry.stale = true;
329 let _ = self.save_inventory(&inv);
330 }
331 }
332
333 fn sweep_stale(&self) -> Vec<String> {
334 if matches!(self.inner.kind, WorkspaceKind::Local) {
336 return Vec::new();
337 }
338 let mut inv = self.load_inventory();
339 let cutoff = SystemTime::now()
340 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
341 let active = self.active_repo_name();
342 let mut swept: Vec<String> = Vec::new();
343 for (rname, entry) in inv.iter_mut() {
344 if entry.stale {
345 continue;
346 }
347 if Some(rname.as_str()) == active.as_deref() {
348 continue;
349 }
350 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
351 if last >= cutoff {
352 continue;
353 }
354 let parts: Vec<&str> = rname.splitn(2, '/').collect();
355 if parts.len() != 2 {
356 continue;
357 }
358 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
359 if repo_path.exists() {
360 let _ = fs::remove_dir_all(&repo_path);
361 }
362 entry.stale = true;
363 swept.push(rname.clone());
364 }
365 if !swept.is_empty() {
366 let _ = self.save_inventory(&inv);
367 self.prune_empty_org_dirs();
368 }
369 swept
370 }
371
372 fn prune_empty_org_dirs(&self) {
373 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
374 return;
375 };
376 for entry in entries.flatten() {
377 let path = entry.path();
378 if !path.is_dir() {
379 continue;
380 }
381 if let Ok(children) = fs::read_dir(&path) {
382 let real: Vec<_> = children
383 .flatten()
384 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
385 .collect();
386 if real.is_empty() {
387 let _ = fs::remove_dir_all(&path);
388 }
389 }
390 }
391 }
392
393 fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
404 if matches!(self.inner.kind, WorkspaceKind::Local) {
405 let root = self
415 .inner
416 .state
417 .read()
418 .unwrap()
419 .active_repo_path
420 .clone()
421 .unwrap_or_else(|| self.inner.workspace_dir.clone());
422 let prev_sha = self.last_built_sha(name);
423 let fingerprint = fingerprint_dir(&root);
424 let action = match prev_sha {
425 Some(p) if p == fingerprint => "current",
426 None => "cloned", Some(_) => "updated",
428 };
429 return Ok((action.to_string(), root, fingerprint));
430 }
431 let parts: Vec<&str> = name.splitn(2, '/').collect();
432 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
433 if !repo_path.exists() {
434 fs::create_dir_all(repo_path.parent().unwrap()).ok();
435 let url = format!("https://github.com/{name}.git");
436 let out = Command::new("git")
437 .args(["clone", "--depth", "1", &url, repo_path.to_str().unwrap()])
438 .output()
439 .context("failed to spawn `git clone`")?;
440 if !out.status.success() {
441 anyhow::bail!(
442 "git clone failed: {}",
443 String::from_utf8_lossy(&out.stderr).trim()
444 );
445 }
446 let sha = git_rev_parse(&repo_path, "HEAD")?;
447 return Ok(("cloned".to_string(), repo_path, sha));
448 }
449
450 Command::new("git")
452 .args(["fetch", "--depth", "1", "origin"])
453 .current_dir(&repo_path)
454 .output()
455 .context("git fetch failed")?;
456 let local = git_rev_parse(&repo_path, "HEAD")?;
457 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
458 if local != remote {
459 Command::new("git")
460 .args(["reset", "--hard", "FETCH_HEAD"])
461 .current_dir(&repo_path)
462 .output()
463 .context("git reset failed")?;
464 let sha = git_rev_parse(&repo_path, "HEAD")?;
465 return Ok(("updated".to_string(), repo_path, sha));
466 }
467 Ok(("current".to_string(), repo_path, local))
468 }
469
470 fn activate(&self, name: &str, force_rebuild: bool) -> Result<String> {
483 let prev_built_sha = self.last_built_sha(name);
484 let (action, repo_path, head_sha) = self.clone_or_update(name)?;
485 self.bump_access(name, &action);
486 let hook_fired_here = {
487 let mut state = self.inner.state.write().unwrap();
488 state.active_repo_name = Some(name.to_string());
489 state.active_repo_path = Some(repo_path.clone());
490 state.hydrated_this_process.contains(name)
491 };
492
493 let already_built = !force_rebuild
501 && action == "current"
502 && prev_built_sha.as_deref() == Some(head_sha.as_str())
503 && hook_fired_here;
504 let mut hook_skipped = false;
505 let hook_ok = if already_built {
506 hook_skipped = true;
507 true
508 } else if let Some(hook) = &self.inner.post_activate {
509 match hook(&repo_path, name) {
510 Ok(()) => true,
511 Err(e) => {
512 tracing::warn!("post-activate hook for {name} failed: {e}");
513 false
514 }
515 }
516 } else {
517 true
520 };
521 if hook_ok {
522 self.record_built_sha(name, &head_sha);
523 }
524 if hook_ok && !hook_skipped {
529 self.inner
530 .state
531 .write()
532 .unwrap()
533 .hydrated_this_process
534 .insert(name.to_string());
535 }
536 let verb = match action.as_str() {
537 "cloned" => "Cloned",
538 "updated" => "Updated",
539 "current" => "Activated (already up to date)",
540 other => other,
541 };
542 let suffix = if hook_skipped {
543 " [build skipped: HEAD matches last-built SHA]"
544 } else {
545 ""
546 };
547 Ok(format!(
548 "{verb} '{name}' at {}.{suffix}",
549 repo_path.display()
550 ))
551 }
552
553 fn record_built_sha(&self, name: &str, sha: &str) {
554 let mut inv = self.load_inventory();
555 if let Some(entry) = inv.get_mut(name) {
556 entry.last_built_sha = Some(sha.to_string());
557 let _ = self.save_inventory(&inv);
558 }
559 }
560
561 pub fn last_built_sha(&self, name: &str) -> Option<String> {
566 self.load_inventory()
567 .get(name)
568 .and_then(|e| e.last_built_sha.clone())
569 }
570
571 fn delete(&self, name: &str) -> Result<String> {
572 let parts: Vec<&str> = name.splitn(2, '/').collect();
573 if parts.len() != 2 {
574 anyhow::bail!("Invalid repo name");
575 }
576 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
577 let mut deleted = Vec::new();
578 if repo_path.exists() {
579 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
580 deleted.push("repo");
581 }
582 self.mark_stale(name);
583 self.prune_empty_org_dirs();
584 if deleted.is_empty() {
585 return Ok(format!("Nothing to delete — '{name}' not found."));
586 }
587 let mut state = self.inner.state.write().unwrap();
588 if state.active_repo_name.as_deref() == Some(name) {
589 state.active_repo_name = None;
590 state.active_repo_path = None;
591 return Ok(format!(
592 "Deleted {}. Active repo cleared.",
593 deleted.join(", ")
594 ));
595 }
596 Ok(format!("Deleted {}.", deleted.join(", ")))
597 }
598
599 fn list(&self) -> String {
600 let inv = self.load_inventory();
601 if inv.is_empty() {
602 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
603 .to_string();
604 }
605 let active = self.active_repo_name();
606 let mut live: Vec<String> = Vec::new();
607 let mut stale_lines: Vec<String> = Vec::new();
608 for (rname, entry) in &inv {
609 let marker = if Some(rname.as_str()) == active.as_deref() {
610 " [active]"
611 } else {
612 ""
613 };
614 let access = format!(
615 "{} access{}, last {}",
616 entry.access_count,
617 if entry.access_count == 1 { "" } else { "es" },
618 relative_time(&entry.last_accessed)
619 );
620 if entry.stale {
621 stale_lines.push(format!(
622 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
623 ));
624 } else {
625 live.push(format!(" {rname}{marker} ({access})"));
626 }
627 }
628 let mut out = String::new();
629 if !live.is_empty() {
630 out.push_str(&format!(
631 "{} live repo(s):\n{}",
632 live.len(),
633 live.join("\n")
634 ));
635 }
636 if !stale_lines.is_empty() {
637 if !out.is_empty() {
638 out.push_str("\n\n");
639 }
640 out.push_str(&format!(
641 "{} stale repo(s):\n{}",
642 stale_lines.len(),
643 stale_lines.join("\n")
644 ));
645 }
646 out
647 }
648
649 pub fn repo_management(
663 &self,
664 name: Option<&str>,
665 delete: bool,
666 update: bool,
667 force_rebuild: bool,
668 ) -> String {
669 if matches!(self.inner.kind, WorkspaceKind::Local) {
671 if name.is_some() {
672 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
673 to switch the active root, or pass `update=true` / `force_rebuild=true` \
674 to rebuild against the current root."
675 .to_string();
676 }
677 if delete {
678 return "Local-workspace mode does not support `delete`. The root is owned by the \
679 operator; remove it manually."
680 .to_string();
681 }
682 let active = match self.active_repo_name() {
683 Some(n) => n,
684 None => return "No active local root.".to_string(),
685 };
686 let _ = update; return self
693 .activate(&active, force_rebuild)
694 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
695 }
696
697 let swept = self.sweep_stale();
698 let prefix = if swept.is_empty() {
699 String::new()
700 } else {
701 format!(
702 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
703 swept.len(),
704 self.inner.stale_after_days,
705 swept.join(", ")
706 )
707 };
708
709 if name.is_none() && !update {
710 return prefix + &self.list();
711 }
712
713 if update {
714 let Some(active) = self.active_repo_name() else {
715 return prefix + "No active repository. Call repo_management('org/repo') first.";
716 };
717 return prefix
718 + &self
719 .activate(&active, force_rebuild)
720 .unwrap_or_else(|e| format!("update failed: {e}"));
721 }
722
723 let Some(name) = name else {
724 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
725 };
726 if let Err(e) = validate_repo_name(name) {
727 return prefix + &e.to_string();
728 }
729 if delete {
730 return prefix
731 + &self
732 .delete(name)
733 .unwrap_or_else(|e| format!("delete failed: {e}"));
734 }
735 prefix
736 + &self
737 .activate(name, force_rebuild)
738 .unwrap_or_else(|e| format!("activate failed: {e}"))
739 }
740
741 pub fn set_root_dir(&self, new_root: &Path) -> String {
744 if !matches!(self.inner.kind, WorkspaceKind::Local) {
745 return "set_root_dir is only valid in local-workspace mode.".to_string();
746 }
747 if !new_root.is_dir() {
748 return format!(
749 "Path does not exist or is not a directory: {}",
750 new_root.display()
751 );
752 }
753 let canon = match new_root.canonicalize() {
754 Ok(p) => p,
755 Err(e) => return format!("canonicalize failed: {e}"),
756 };
757 let synthetic = synthesize_local_name(&canon);
758 {
759 let mut state = self.inner.state.write().unwrap();
760 state.active_repo_name = Some(synthetic.clone());
761 state.active_repo_path = Some(canon.clone());
762 }
763 self.activate(&synthetic, false)
767 .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
768 }
769}
770
771fn synthesize_local_name(root: &Path) -> String {
775 let name = root
776 .file_name()
777 .map(|s| s.to_string_lossy().into_owned())
778 .unwrap_or_else(|| "local".to_string());
779 format!("local/{name}")
780}
781
782fn parse_origin_repo(root: &Path) -> Option<String> {
793 let out = Command::new("git")
794 .arg("-C")
795 .arg(root)
796 .args(["remote", "get-url", "origin"])
797 .output()
798 .ok()?;
799 if !out.status.success() {
800 return None;
801 }
802 let url = String::from_utf8(out.stdout).ok()?;
803 parse_github_remote(url.trim())
804}
805
806fn parse_github_remote(url: &str) -> Option<String> {
809 let path = url
813 .strip_prefix("git@github.com:")
814 .or_else(|| url.strip_prefix("https://github.com/"))
815 .or_else(|| url.strip_prefix("http://github.com/"))
816 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
817 let path = path.strip_suffix(".git").unwrap_or(path);
818 let path = path.trim_end_matches('/');
819 let mut parts = path.split('/');
821 let org = parts.next().filter(|s| !s.is_empty())?;
822 let repo = parts.next().filter(|s| !s.is_empty())?;
823 if parts.next().is_some() {
824 return None;
825 }
826 Some(format!("{org}/{repo}"))
827}
828
829fn fingerprint_dir(root: &Path) -> String {
834 use std::hash::{Hash, Hasher};
835 let mut hasher = std::collections::hash_map::DefaultHasher::new();
836 let walker = ignore::WalkBuilder::new(root)
837 .standard_filters(true)
838 .hidden(true)
839 .git_ignore(true)
840 .build();
841 for entry in walker.flatten() {
842 if !entry.path().is_file() {
843 continue;
844 }
845 let Ok(meta) = entry.metadata() else { continue };
846 let mtime = meta
847 .modified()
848 .ok()
849 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
850 .map(|d| d.as_secs())
851 .unwrap_or(0);
852 entry.path().to_string_lossy().hash(&mut hasher);
853 mtime.hash(&mut hasher);
854 meta.len().hash(&mut hasher);
855 }
856 format!("local-{:016x}", hasher.finish())
857}
858
859fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
860 let out = Command::new("git")
861 .args(["rev-parse", refspec])
862 .current_dir(repo_path)
863 .output()
864 .context("git rev-parse failed")?;
865 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
866}
867
868fn now_iso() -> String {
869 format_iso(SystemTime::now())
870}
871
872fn format_iso(t: SystemTime) -> String {
873 let secs = t
874 .duration_since(SystemTime::UNIX_EPOCH)
875 .map(|d| d.as_secs())
876 .unwrap_or(0);
877 chrono_lite::format_secs(secs)
879}
880
881fn parse_iso(s: &str) -> Option<SystemTime> {
882 let secs = chrono_lite::parse_secs(s)?;
883 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
884}
885
886fn relative_time(iso: &str) -> String {
887 let Some(t) = parse_iso(iso) else {
888 return "unknown".to_string();
889 };
890 let now = SystemTime::now();
891 let delta = now.duration_since(t).unwrap_or_default().as_secs();
892 if delta < 3600 {
893 "just now".to_string()
894 } else if delta < 86_400 {
895 format!("{}h ago", delta / 3600)
896 } else {
897 format!("{}d ago", delta / 86_400)
898 }
899}
900
901mod chrono_lite {
904 pub fn format_secs(secs: u64) -> String {
905 let days = (secs / 86_400) as i64;
907 let time = secs % 86_400;
908 let (y, mo, d) = days_to_civil(days + 719_468);
909 let h = time / 3600;
910 let m = (time / 60) % 60;
911 let s = time % 60;
912 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
913 }
914
915 pub fn parse_secs(s: &str) -> Option<u64> {
916 let bytes = s.as_bytes();
919 if bytes.len() < 19 {
920 return None;
921 }
922 let y: i64 = s.get(0..4)?.parse().ok()?;
923 let mo: u32 = s.get(5..7)?.parse().ok()?;
924 let d: u32 = s.get(8..10)?.parse().ok()?;
925 let h: u64 = s.get(11..13)?.parse().ok()?;
926 let m: u64 = s.get(14..16)?.parse().ok()?;
927 let sc: u64 = s.get(17..19)?.parse().ok()?;
928 let days = civil_to_days(y, mo, d) - 719_468;
929 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
930 }
931
932 fn days_to_civil(z: i64) -> (i64, u32, u32) {
933 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
934 let doe = (z - era * 146_097) as u64;
935 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
936 let y = (yoe as i64) + era * 400;
937 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
938 let mp = (5 * doy + 2) / 153;
939 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
940 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
941 let y = if m <= 2 { y + 1 } else { y };
942 (y, m, d)
943 }
944
945 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
946 let y = if m <= 2 { y - 1 } else { y };
947 let era = if y >= 0 { y } else { y - 399 } / 400;
948 let yoe = (y - era * 400) as u64;
949 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
950 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
951 era * 146_097 + doe as i64
952 }
953}
954
955#[allow(dead_code)]
957fn _json_keepalive() {
958 let _ = json!({});
959}
960
961#[cfg(test)]
962mod tests {
963 use super::*;
964
965 #[test]
966 fn validates_repo_names() {
967 assert!(validate_repo_name("pydata/xarray").is_ok());
968 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
969 assert!(validate_repo_name("xarray").is_err());
970 assert!(validate_repo_name("a/b/c").is_err());
971 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
972 }
973
974 #[test]
975 fn open_creates_layout() {
976 let dir = tempfile::tempdir().unwrap();
977 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
978 assert!(ws.repos_dir().is_dir());
979 }
980
981 #[test]
982 fn empty_list() {
983 let dir = tempfile::tempdir().unwrap();
984 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
985 let out = ws.repo_management(None, false, false, false);
986 assert!(out.contains("No repos cloned yet"));
987 }
988
989 #[test]
990 fn invalid_repo_name_rejected() {
991 let dir = tempfile::tempdir().unwrap();
992 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
993 let out = ws.repo_management(Some("bad name with spaces"), false, false, false);
994 assert!(out.contains("Invalid repo name"));
995 }
996
997 #[test]
998 fn delete_unknown() {
999 let dir = tempfile::tempdir().unwrap();
1000 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1001 let out = ws.repo_management(Some("nope/none"), true, false, false);
1002 assert!(out.contains("Nothing to delete"));
1003 }
1004
1005 #[test]
1006 fn iso_round_trip() {
1007 let now = SystemTime::now()
1008 .duration_since(SystemTime::UNIX_EPOCH)
1009 .unwrap()
1010 .as_secs();
1011 let s = chrono_lite::format_secs(now);
1012 let back = chrono_lite::parse_secs(&s).unwrap();
1013 assert_eq!(now, back);
1014 }
1015
1016 #[test]
1017 fn last_built_sha_round_trip() {
1018 let dir = tempfile::tempdir().unwrap();
1019 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1020 ws.bump_access("acme/widgets", "cloned");
1022 assert_eq!(ws.last_built_sha("acme/widgets"), None);
1023 ws.record_built_sha("acme/widgets", "abc1234deadbeef");
1024 assert_eq!(
1025 ws.last_built_sha("acme/widgets").as_deref(),
1026 Some("abc1234deadbeef")
1027 );
1028 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1030 assert_eq!(
1031 ws2.last_built_sha("acme/widgets").as_deref(),
1032 Some("abc1234deadbeef")
1033 );
1034 }
1035
1036 #[test]
1037 fn inventory_loads_legacy_entries_without_sha_field() {
1038 let dir = tempfile::tempdir().unwrap();
1039 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1040 let legacy = r#"{
1042 "old/repo": {
1043 "cloned_at": "2024-01-01T00:00:00",
1044 "last_accessed": "2024-01-01T00:00:00",
1045 "access_count": 5,
1046 "stale": false
1047 }
1048 }"#;
1049 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1050 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1052 assert_eq!(ws2.last_built_sha("old/repo"), None);
1053 let _ = ws;
1054 }
1055
1056 #[test]
1057 fn auto_rebuild_gate_skips_when_sha_matches() {
1058 use std::sync::atomic::{AtomicUsize, Ordering};
1059 let dir = tempfile::tempdir().unwrap();
1060 let calls = Arc::new(AtomicUsize::new(0));
1061 let calls_h = calls.clone();
1062 let hook: PostActivateHook = Arc::new(move |_path, _name| {
1063 calls_h.fetch_add(1, Ordering::SeqCst);
1064 Ok(())
1065 });
1066 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1072 ws.bump_access("acme/widgets", "cloned");
1074 ws.record_built_sha("acme/widgets", "sha_one");
1075 assert_eq!(
1076 ws.last_built_sha("acme/widgets").as_deref(),
1077 Some("sha_one")
1078 );
1079 ws.record_built_sha("acme/widgets", "sha_one");
1082 assert_eq!(
1083 ws.last_built_sha("acme/widgets").as_deref(),
1084 Some("sha_one")
1085 );
1086 assert_eq!(calls.load(Ordering::SeqCst), 0);
1089 }
1090
1091 #[test]
1092 fn local_workspace_binds_root_immediately() {
1093 let dir = tempfile::tempdir().unwrap();
1094 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1095 assert_eq!(ws.kind(), WorkspaceKind::Local);
1096 assert!(ws.active_repo_path().is_some());
1097 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1098 }
1099
1100 #[test]
1101 fn local_workspace_rejects_github_ops() {
1102 let dir = tempfile::tempdir().unwrap();
1103 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1104 let out = ws.repo_management(Some("acme/widgets"), false, false, false);
1105 assert!(out.contains("does not accept a repo name"));
1106 let out = ws.repo_management(None, true, false, false);
1107 assert!(out.contains("does not support `delete`"));
1108 }
1109
1110 #[test]
1111 fn local_workspace_update_rebuilds() {
1112 use std::sync::atomic::{AtomicUsize, Ordering};
1113 let dir = tempfile::tempdir().unwrap();
1114 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1116 let calls = Arc::new(AtomicUsize::new(0));
1117 let calls_h = calls.clone();
1118 let hook: PostActivateHook = Arc::new(move |_p, _n| {
1119 calls_h.fetch_add(1, Ordering::SeqCst);
1120 Ok(())
1121 });
1122 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1123 let _ = ws.repo_management(None, false, true, false);
1125 assert_eq!(calls.load(Ordering::SeqCst), 1);
1126 let out = ws.repo_management(None, false, true, false);
1128 assert_eq!(
1129 calls.load(Ordering::SeqCst),
1130 1,
1131 "auto-rebuild gate must skip"
1132 );
1133 assert!(out.contains("build skipped"));
1134 }
1135
1136 #[test]
1137 fn parses_github_remote_forms() {
1138 assert_eq!(
1139 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1140 Some("kkollsga/kglite")
1141 );
1142 assert_eq!(
1143 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1144 Some("kkollsga/kglite")
1145 );
1146 assert_eq!(
1148 parse_github_remote("https://github.com/acme/widget/").as_deref(),
1149 Some("acme/widget")
1150 );
1151 assert_eq!(
1152 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1153 Some("acme/widget")
1154 );
1155 assert_eq!(
1157 parse_github_remote("https://gitlab.com/acme/widget.git"),
1158 None
1159 );
1160 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1161 assert_eq!(parse_github_remote("not a url"), None);
1162 }
1163
1164 #[test]
1165 fn local_default_github_repo_uses_origin_remote() {
1166 let dir = tempfile::tempdir().unwrap();
1167 let root = dir.path();
1168 let git = |args: &[&str]| {
1171 Command::new("git")
1172 .arg("-C")
1173 .arg(root)
1174 .args(args)
1175 .output()
1176 .unwrap()
1177 };
1178 if !git(&["init"]).status.success() {
1179 return;
1181 }
1182 git(&[
1183 "remote",
1184 "add",
1185 "origin",
1186 "https://github.com/acme/widget.git",
1187 ]);
1188 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1189 assert_eq!(
1190 ws.default_github_repo().as_deref(),
1191 Some("acme/widget"),
1192 "local default repo must come from the origin remote, not the inventory key"
1193 );
1194 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1196 }
1197
1198 #[test]
1199 fn local_default_github_repo_none_without_remote() {
1200 let dir = tempfile::tempdir().unwrap();
1201 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1202 let def = ws.default_github_repo();
1204 assert!(
1205 def.is_none(),
1206 "expected None for a non-git local root, got {def:?}"
1207 );
1208 }
1209
1210 #[test]
1211 fn set_root_dir_only_in_local_mode() {
1212 let dir = tempfile::tempdir().unwrap();
1213 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1214 let out = ws.set_root_dir(dir.path());
1215 assert!(out.contains("only valid in local-workspace"));
1216 }
1217
1218 #[test]
1219 fn update_with_no_active_repo() {
1220 let dir = tempfile::tempdir().unwrap();
1221 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1222 let out = ws.repo_management(None, false, true, false);
1223 assert!(out.contains("No active repository"));
1224 }
1225
1226 #[test]
1227 fn set_root_dir_updates_active_path() {
1228 let dir = tempfile::tempdir().unwrap();
1229 let child = dir.path().join("child");
1230 std::fs::create_dir_all(&child).unwrap();
1231 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1232 let _ = ws.set_root_dir(&child);
1233 assert_eq!(
1234 ws.active_repo_path().unwrap(),
1235 child.canonicalize().unwrap(),
1236 "set_root_dir didn't update active_repo_path"
1237 );
1238 }
1239
1240 #[test]
1241 fn set_root_dir_post_activate_fires_against_new_root() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let child = dir.path().join("child");
1244 std::fs::create_dir_all(&child).unwrap();
1245 std::fs::write(child.join("a.txt"), b"hi").unwrap();
1246 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1247 let seen = seen_path.clone();
1248 let hook: PostActivateHook = Arc::new(move |p, _n| {
1249 *seen.lock().unwrap() = Some(p.to_path_buf());
1250 Ok(())
1251 });
1252 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1253 let _ = ws.set_root_dir(&child);
1254 assert_eq!(
1255 seen_path.lock().unwrap().clone().unwrap(),
1256 child.canonicalize().unwrap(),
1257 "post_activate hook saw the wrong root after set_root_dir"
1258 );
1259 }
1260
1261 #[test]
1262 fn hook_fires_once_per_process_even_when_sha_matches() {
1263 use std::sync::atomic::{AtomicUsize, Ordering};
1264 let dir = tempfile::tempdir().unwrap();
1268 std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1269
1270 let calls = Arc::new(AtomicUsize::new(0));
1271 let make_hook = || -> PostActivateHook {
1272 let c = calls.clone();
1273 Arc::new(move |_p, _n| {
1274 c.fetch_add(1, Ordering::SeqCst);
1275 Ok(())
1276 })
1277 };
1278
1279 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1281 let _ = ws.repo_management(None, false, true, false);
1283 assert_eq!(
1284 calls.load(Ordering::SeqCst),
1285 1,
1286 "first activate must hydrate"
1287 );
1288 let out = ws.repo_management(None, false, true, false);
1290 assert_eq!(
1291 calls.load(Ordering::SeqCst),
1292 1,
1293 "repeat activate in same process must skip the hook"
1294 );
1295 assert!(
1296 out.contains("build skipped"),
1297 "expected skip suffix, got: {out}"
1298 );
1299 drop(ws);
1300
1301 let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1306 assert!(
1307 ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1308 .is_some(),
1309 "sanity: last_built_sha should survive the restart"
1310 );
1311 let _ = ws2.repo_management(None, false, true, false);
1312 assert_eq!(
1313 calls.load(Ordering::SeqCst),
1314 2,
1315 "fresh process must re-fire the hook even when the SHA matches"
1316 );
1317 }
1318}