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
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}
107
108impl Workspace {
109 pub fn open(
111 workspace_dir: PathBuf,
112 stale_after_days: u32,
113 post_activate: Option<PostActivateHook>,
114 ) -> Result<Self> {
115 if !workspace_dir.is_dir() {
116 fs::create_dir_all(&workspace_dir).with_context(|| {
117 format!("failed to create workspace dir {}", workspace_dir.display())
118 })?;
119 }
120 let repos_dir = workspace_dir.join("repos");
121 if !repos_dir.is_dir() {
122 fs::create_dir_all(&repos_dir)
123 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
124 }
125 let ws = Self {
126 inner: Arc::new(WorkspaceInner {
127 kind: WorkspaceKind::Github,
128 workspace_dir,
129 stale_after_days,
130 state: RwLock::new(WorkspaceState::default()),
131 post_activate,
132 }),
133 };
134 ws.reconcile_inventory()?;
135 Ok(ws)
136 }
137
138 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
146 if !root.is_dir() {
147 anyhow::bail!(
148 "local workspace root does not exist or is not a directory: {}",
149 root.display()
150 );
151 }
152 let canon_root = root
153 .canonicalize()
154 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
155 let inv_dir = canon_root.join(".mcp-workspace");
158 if !inv_dir.is_dir() {
159 fs::create_dir_all(&inv_dir).with_context(|| {
160 format!("failed to create local-workspace dir {}", inv_dir.display())
161 })?;
162 }
163 let mut state = WorkspaceState::default();
164 let synthetic_name = synthesize_local_name(&canon_root);
165 state.active_repo_name = Some(synthetic_name);
166 state.active_repo_path = Some(canon_root.clone());
167 Ok(Self {
168 inner: Arc::new(WorkspaceInner {
169 kind: WorkspaceKind::Local,
170 workspace_dir: canon_root,
171 stale_after_days: u32::MAX, state: RwLock::new(state),
173 post_activate,
174 }),
175 })
176 }
177
178 pub fn kind(&self) -> WorkspaceKind {
179 self.inner.kind
180 }
181
182 pub fn workspace_dir(&self) -> &Path {
183 &self.inner.workspace_dir
184 }
185
186 pub fn repos_dir(&self) -> PathBuf {
187 self.inner.workspace_dir.join("repos")
188 }
189
190 fn inventory_path(&self) -> PathBuf {
191 match self.inner.kind {
192 WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
193 WorkspaceKind::Local => self
194 .inner
195 .workspace_dir
196 .join(".mcp-workspace")
197 .join("inventory.json"),
198 }
199 }
200
201 pub fn active_repo_name(&self) -> Option<String> {
203 self.inner.state.read().unwrap().active_repo_name.clone()
204 }
205
206 pub fn active_repo_path(&self) -> Option<PathBuf> {
208 self.inner.state.read().unwrap().active_repo_path.clone()
209 }
210
211 pub fn default_github_repo(&self) -> Option<String> {
220 match self.inner.kind {
221 WorkspaceKind::Github => self.active_repo_name(),
222 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
223 }
224 }
225
226 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
231 let path = self.inventory_path();
232 let Ok(text) = fs::read_to_string(&path) else {
233 return BTreeMap::new();
234 };
235 serde_json::from_str(&text).unwrap_or_default()
236 }
237
238 fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
239 let path = self.inventory_path();
240 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
241 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
242 Ok(())
243 }
244
245 fn reconcile_inventory(&self) -> Result<()> {
246 let mut inv = self.load_inventory();
247 let mut on_disk: Vec<String> = Vec::new();
248 if self.repos_dir().is_dir() {
249 for org_entry in fs::read_dir(self.repos_dir())? {
250 let Ok(org_entry) = org_entry else { continue };
251 if !org_entry.path().is_dir() {
252 continue;
253 }
254 let org = org_entry.file_name().to_string_lossy().into_owned();
255 if org.starts_with('.') {
256 continue;
257 }
258 for repo_entry in fs::read_dir(org_entry.path())? {
259 let Ok(repo_entry) = repo_entry else { continue };
260 if !repo_entry.path().is_dir() {
261 continue;
262 }
263 let repo = repo_entry.file_name().to_string_lossy().into_owned();
264 if repo.starts_with('.') {
265 continue;
266 }
267 let rname = format!("{org}/{repo}");
268 on_disk.push(rname.clone());
269 inv.entry(rname).or_insert_with(|| {
270 let mtime = repo_entry
271 .metadata()
272 .ok()
273 .and_then(|m| m.modified().ok())
274 .map(format_iso)
275 .unwrap_or_else(now_iso);
276 InventoryEntry {
277 cloned_at: mtime.clone(),
278 last_accessed: mtime,
279 access_count: 0,
280 stale: false,
281 last_built_sha: None,
282 }
283 });
284 }
285 }
286 }
287 for (rname, entry) in inv.iter_mut() {
288 if !on_disk.contains(rname) && !entry.stale {
289 entry.stale = true;
290 }
291 }
292 self.save_inventory(&inv)?;
293 Ok(())
294 }
295
296 fn bump_access(&self, name: &str, action: &str) {
297 let mut inv = self.load_inventory();
298 let now = now_iso();
299 let entry = inv
300 .entry(name.to_string())
301 .or_insert_with(|| InventoryEntry {
302 cloned_at: now.clone(),
303 last_accessed: now.clone(),
304 access_count: 0,
305 stale: false,
306 last_built_sha: None,
307 });
308 entry.last_accessed = now.clone();
309 entry.access_count += 1;
310 entry.stale = false;
311 if action == "cloned" || entry.cloned_at.is_empty() {
312 entry.cloned_at = now;
313 }
314 let _ = self.save_inventory(&inv);
315 }
316
317 fn mark_stale(&self, name: &str) {
318 let mut inv = self.load_inventory();
319 if let Some(entry) = inv.get_mut(name) {
320 entry.stale = true;
321 let _ = self.save_inventory(&inv);
322 }
323 }
324
325 fn sweep_stale(&self) -> Vec<String> {
326 if matches!(self.inner.kind, WorkspaceKind::Local) {
328 return Vec::new();
329 }
330 let mut inv = self.load_inventory();
331 let cutoff = SystemTime::now()
332 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
333 let active = self.active_repo_name();
334 let mut swept: Vec<String> = Vec::new();
335 for (rname, entry) in inv.iter_mut() {
336 if entry.stale {
337 continue;
338 }
339 if Some(rname.as_str()) == active.as_deref() {
340 continue;
341 }
342 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
343 if last >= cutoff {
344 continue;
345 }
346 let parts: Vec<&str> = rname.splitn(2, '/').collect();
347 if parts.len() != 2 {
348 continue;
349 }
350 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
351 if repo_path.exists() {
352 let _ = fs::remove_dir_all(&repo_path);
353 }
354 entry.stale = true;
355 swept.push(rname.clone());
356 }
357 if !swept.is_empty() {
358 let _ = self.save_inventory(&inv);
359 self.prune_empty_org_dirs();
360 }
361 swept
362 }
363
364 fn prune_empty_org_dirs(&self) {
365 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
366 return;
367 };
368 for entry in entries.flatten() {
369 let path = entry.path();
370 if !path.is_dir() {
371 continue;
372 }
373 if let Ok(children) = fs::read_dir(&path) {
374 let real: Vec<_> = children
375 .flatten()
376 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
377 .collect();
378 if real.is_empty() {
379 let _ = fs::remove_dir_all(&path);
380 }
381 }
382 }
383 }
384
385 fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
396 if matches!(self.inner.kind, WorkspaceKind::Local) {
397 let root = self
407 .inner
408 .state
409 .read()
410 .unwrap()
411 .active_repo_path
412 .clone()
413 .unwrap_or_else(|| self.inner.workspace_dir.clone());
414 let prev_sha = self.last_built_sha(name);
415 let fingerprint = fingerprint_dir(&root);
416 let action = match prev_sha {
417 Some(p) if p == fingerprint => "current",
418 None => "cloned", Some(_) => "updated",
420 };
421 return Ok((action.to_string(), root, fingerprint));
422 }
423 let parts: Vec<&str> = name.splitn(2, '/').collect();
424 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
425 if !repo_path.exists() {
426 fs::create_dir_all(repo_path.parent().unwrap()).ok();
427 let url = format!("https://github.com/{name}.git");
428 let out = Command::new("git")
429 .args(["clone", "--depth", "1", &url, repo_path.to_str().unwrap()])
430 .output()
431 .context("failed to spawn `git clone`")?;
432 if !out.status.success() {
433 anyhow::bail!(
434 "git clone failed: {}",
435 String::from_utf8_lossy(&out.stderr).trim()
436 );
437 }
438 let sha = git_rev_parse(&repo_path, "HEAD")?;
439 return Ok(("cloned".to_string(), repo_path, sha));
440 }
441
442 Command::new("git")
444 .args(["fetch", "--depth", "1", "origin"])
445 .current_dir(&repo_path)
446 .output()
447 .context("git fetch failed")?;
448 let local = git_rev_parse(&repo_path, "HEAD")?;
449 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
450 if local != remote {
451 Command::new("git")
452 .args(["reset", "--hard", "FETCH_HEAD"])
453 .current_dir(&repo_path)
454 .output()
455 .context("git reset failed")?;
456 let sha = git_rev_parse(&repo_path, "HEAD")?;
457 return Ok(("updated".to_string(), repo_path, sha));
458 }
459 Ok(("current".to_string(), repo_path, local))
460 }
461
462 fn activate(&self, name: &str, force_rebuild: bool) -> Result<String> {
475 let prev_built_sha = self.last_built_sha(name);
476 let (action, repo_path, head_sha) = self.clone_or_update(name)?;
477 self.bump_access(name, &action);
478 {
479 let mut state = self.inner.state.write().unwrap();
480 state.active_repo_name = Some(name.to_string());
481 state.active_repo_path = Some(repo_path.clone());
482 }
483
484 let already_built = !force_rebuild
485 && action == "current"
486 && prev_built_sha.as_deref() == Some(head_sha.as_str());
487 let mut hook_skipped = false;
488 let hook_ok = if already_built {
489 hook_skipped = true;
490 true
491 } else if let Some(hook) = &self.inner.post_activate {
492 match hook(&repo_path, name) {
493 Ok(()) => true,
494 Err(e) => {
495 tracing::warn!("post-activate hook for {name} failed: {e}");
496 false
497 }
498 }
499 } else {
500 true
503 };
504 if hook_ok {
505 self.record_built_sha(name, &head_sha);
506 }
507 let verb = match action.as_str() {
508 "cloned" => "Cloned",
509 "updated" => "Updated",
510 "current" => "Activated (already up to date)",
511 other => other,
512 };
513 let suffix = if hook_skipped {
514 " [build skipped: HEAD matches last-built SHA]"
515 } else {
516 ""
517 };
518 Ok(format!(
519 "{verb} '{name}' at {}.{suffix}",
520 repo_path.display()
521 ))
522 }
523
524 fn record_built_sha(&self, name: &str, sha: &str) {
525 let mut inv = self.load_inventory();
526 if let Some(entry) = inv.get_mut(name) {
527 entry.last_built_sha = Some(sha.to_string());
528 let _ = self.save_inventory(&inv);
529 }
530 }
531
532 pub fn last_built_sha(&self, name: &str) -> Option<String> {
537 self.load_inventory()
538 .get(name)
539 .and_then(|e| e.last_built_sha.clone())
540 }
541
542 fn delete(&self, name: &str) -> Result<String> {
543 let parts: Vec<&str> = name.splitn(2, '/').collect();
544 if parts.len() != 2 {
545 anyhow::bail!("Invalid repo name");
546 }
547 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
548 let mut deleted = Vec::new();
549 if repo_path.exists() {
550 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
551 deleted.push("repo");
552 }
553 self.mark_stale(name);
554 self.prune_empty_org_dirs();
555 if deleted.is_empty() {
556 return Ok(format!("Nothing to delete — '{name}' not found."));
557 }
558 let mut state = self.inner.state.write().unwrap();
559 if state.active_repo_name.as_deref() == Some(name) {
560 state.active_repo_name = None;
561 state.active_repo_path = None;
562 return Ok(format!(
563 "Deleted {}. Active repo cleared.",
564 deleted.join(", ")
565 ));
566 }
567 Ok(format!("Deleted {}.", deleted.join(", ")))
568 }
569
570 fn list(&self) -> String {
571 let inv = self.load_inventory();
572 if inv.is_empty() {
573 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
574 .to_string();
575 }
576 let active = self.active_repo_name();
577 let mut live: Vec<String> = Vec::new();
578 let mut stale_lines: Vec<String> = Vec::new();
579 for (rname, entry) in &inv {
580 let marker = if Some(rname.as_str()) == active.as_deref() {
581 " [active]"
582 } else {
583 ""
584 };
585 let access = format!(
586 "{} access{}, last {}",
587 entry.access_count,
588 if entry.access_count == 1 { "" } else { "es" },
589 relative_time(&entry.last_accessed)
590 );
591 if entry.stale {
592 stale_lines.push(format!(
593 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
594 ));
595 } else {
596 live.push(format!(" {rname}{marker} ({access})"));
597 }
598 }
599 let mut out = String::new();
600 if !live.is_empty() {
601 out.push_str(&format!(
602 "{} live repo(s):\n{}",
603 live.len(),
604 live.join("\n")
605 ));
606 }
607 if !stale_lines.is_empty() {
608 if !out.is_empty() {
609 out.push_str("\n\n");
610 }
611 out.push_str(&format!(
612 "{} stale repo(s):\n{}",
613 stale_lines.len(),
614 stale_lines.join("\n")
615 ));
616 }
617 out
618 }
619
620 pub fn repo_management(
634 &self,
635 name: Option<&str>,
636 delete: bool,
637 update: bool,
638 force_rebuild: bool,
639 ) -> String {
640 if matches!(self.inner.kind, WorkspaceKind::Local) {
642 if name.is_some() {
643 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
644 to switch the active root, or pass `update=true` / `force_rebuild=true` \
645 to rebuild against the current root."
646 .to_string();
647 }
648 if delete {
649 return "Local-workspace mode does not support `delete`. The root is owned by the \
650 operator; remove it manually."
651 .to_string();
652 }
653 let active = match self.active_repo_name() {
654 Some(n) => n,
655 None => return "No active local root.".to_string(),
656 };
657 let _ = update; return self
664 .activate(&active, force_rebuild)
665 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
666 }
667
668 let swept = self.sweep_stale();
669 let prefix = if swept.is_empty() {
670 String::new()
671 } else {
672 format!(
673 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
674 swept.len(),
675 self.inner.stale_after_days,
676 swept.join(", ")
677 )
678 };
679
680 if name.is_none() && !update {
681 return prefix + &self.list();
682 }
683
684 if update {
685 let Some(active) = self.active_repo_name() else {
686 return prefix + "No active repository. Call repo_management('org/repo') first.";
687 };
688 return prefix
689 + &self
690 .activate(&active, force_rebuild)
691 .unwrap_or_else(|e| format!("update failed: {e}"));
692 }
693
694 let Some(name) = name else {
695 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
696 };
697 if let Err(e) = validate_repo_name(name) {
698 return prefix + &e.to_string();
699 }
700 if delete {
701 return prefix
702 + &self
703 .delete(name)
704 .unwrap_or_else(|e| format!("delete failed: {e}"));
705 }
706 prefix
707 + &self
708 .activate(name, force_rebuild)
709 .unwrap_or_else(|e| format!("activate failed: {e}"))
710 }
711
712 pub fn set_root_dir(&self, new_root: &Path) -> String {
715 if !matches!(self.inner.kind, WorkspaceKind::Local) {
716 return "set_root_dir is only valid in local-workspace mode.".to_string();
717 }
718 if !new_root.is_dir() {
719 return format!(
720 "Path does not exist or is not a directory: {}",
721 new_root.display()
722 );
723 }
724 let canon = match new_root.canonicalize() {
725 Ok(p) => p,
726 Err(e) => return format!("canonicalize failed: {e}"),
727 };
728 let synthetic = synthesize_local_name(&canon);
729 {
730 let mut state = self.inner.state.write().unwrap();
731 state.active_repo_name = Some(synthetic.clone());
732 state.active_repo_path = Some(canon.clone());
733 }
734 self.activate(&synthetic, false)
738 .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
739 }
740}
741
742fn synthesize_local_name(root: &Path) -> String {
746 let name = root
747 .file_name()
748 .map(|s| s.to_string_lossy().into_owned())
749 .unwrap_or_else(|| "local".to_string());
750 format!("local/{name}")
751}
752
753fn parse_origin_repo(root: &Path) -> Option<String> {
764 let out = Command::new("git")
765 .arg("-C")
766 .arg(root)
767 .args(["remote", "get-url", "origin"])
768 .output()
769 .ok()?;
770 if !out.status.success() {
771 return None;
772 }
773 let url = String::from_utf8(out.stdout).ok()?;
774 parse_github_remote(url.trim())
775}
776
777fn parse_github_remote(url: &str) -> Option<String> {
780 let path = url
784 .strip_prefix("git@github.com:")
785 .or_else(|| url.strip_prefix("https://github.com/"))
786 .or_else(|| url.strip_prefix("http://github.com/"))
787 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
788 let path = path.strip_suffix(".git").unwrap_or(path);
789 let path = path.trim_end_matches('/');
790 let mut parts = path.split('/');
792 let org = parts.next().filter(|s| !s.is_empty())?;
793 let repo = parts.next().filter(|s| !s.is_empty())?;
794 if parts.next().is_some() {
795 return None;
796 }
797 Some(format!("{org}/{repo}"))
798}
799
800fn fingerprint_dir(root: &Path) -> String {
805 use std::hash::{Hash, Hasher};
806 let mut hasher = std::collections::hash_map::DefaultHasher::new();
807 let walker = ignore::WalkBuilder::new(root)
808 .standard_filters(true)
809 .hidden(true)
810 .git_ignore(true)
811 .build();
812 for entry in walker.flatten() {
813 if !entry.path().is_file() {
814 continue;
815 }
816 let Ok(meta) = entry.metadata() else { continue };
817 let mtime = meta
818 .modified()
819 .ok()
820 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
821 .map(|d| d.as_secs())
822 .unwrap_or(0);
823 entry.path().to_string_lossy().hash(&mut hasher);
824 mtime.hash(&mut hasher);
825 meta.len().hash(&mut hasher);
826 }
827 format!("local-{:016x}", hasher.finish())
828}
829
830fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
831 let out = Command::new("git")
832 .args(["rev-parse", refspec])
833 .current_dir(repo_path)
834 .output()
835 .context("git rev-parse failed")?;
836 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
837}
838
839fn now_iso() -> String {
840 format_iso(SystemTime::now())
841}
842
843fn format_iso(t: SystemTime) -> String {
844 let secs = t
845 .duration_since(SystemTime::UNIX_EPOCH)
846 .map(|d| d.as_secs())
847 .unwrap_or(0);
848 chrono_lite::format_secs(secs)
850}
851
852fn parse_iso(s: &str) -> Option<SystemTime> {
853 let secs = chrono_lite::parse_secs(s)?;
854 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
855}
856
857fn relative_time(iso: &str) -> String {
858 let Some(t) = parse_iso(iso) else {
859 return "unknown".to_string();
860 };
861 let now = SystemTime::now();
862 let delta = now.duration_since(t).unwrap_or_default().as_secs();
863 if delta < 3600 {
864 "just now".to_string()
865 } else if delta < 86_400 {
866 format!("{}h ago", delta / 3600)
867 } else {
868 format!("{}d ago", delta / 86_400)
869 }
870}
871
872mod chrono_lite {
875 pub fn format_secs(secs: u64) -> String {
876 let days = (secs / 86_400) as i64;
878 let time = secs % 86_400;
879 let (y, mo, d) = days_to_civil(days + 719_468);
880 let h = time / 3600;
881 let m = (time / 60) % 60;
882 let s = time % 60;
883 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
884 }
885
886 pub fn parse_secs(s: &str) -> Option<u64> {
887 let bytes = s.as_bytes();
890 if bytes.len() < 19 {
891 return None;
892 }
893 let y: i64 = s.get(0..4)?.parse().ok()?;
894 let mo: u32 = s.get(5..7)?.parse().ok()?;
895 let d: u32 = s.get(8..10)?.parse().ok()?;
896 let h: u64 = s.get(11..13)?.parse().ok()?;
897 let m: u64 = s.get(14..16)?.parse().ok()?;
898 let sc: u64 = s.get(17..19)?.parse().ok()?;
899 let days = civil_to_days(y, mo, d) - 719_468;
900 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
901 }
902
903 fn days_to_civil(z: i64) -> (i64, u32, u32) {
904 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
905 let doe = (z - era * 146_097) as u64;
906 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
907 let y = (yoe as i64) + era * 400;
908 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
909 let mp = (5 * doy + 2) / 153;
910 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
911 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
912 let y = if m <= 2 { y + 1 } else { y };
913 (y, m, d)
914 }
915
916 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
917 let y = if m <= 2 { y - 1 } else { y };
918 let era = if y >= 0 { y } else { y - 399 } / 400;
919 let yoe = (y - era * 400) as u64;
920 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
921 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
922 era * 146_097 + doe as i64
923 }
924}
925
926#[allow(dead_code)]
928fn _json_keepalive() {
929 let _ = json!({});
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
937 fn validates_repo_names() {
938 assert!(validate_repo_name("pydata/xarray").is_ok());
939 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
940 assert!(validate_repo_name("xarray").is_err());
941 assert!(validate_repo_name("a/b/c").is_err());
942 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
943 }
944
945 #[test]
946 fn open_creates_layout() {
947 let dir = tempfile::tempdir().unwrap();
948 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
949 assert!(ws.repos_dir().is_dir());
950 }
951
952 #[test]
953 fn empty_list() {
954 let dir = tempfile::tempdir().unwrap();
955 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
956 let out = ws.repo_management(None, false, false, false);
957 assert!(out.contains("No repos cloned yet"));
958 }
959
960 #[test]
961 fn invalid_repo_name_rejected() {
962 let dir = tempfile::tempdir().unwrap();
963 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
964 let out = ws.repo_management(Some("bad name with spaces"), false, false, false);
965 assert!(out.contains("Invalid repo name"));
966 }
967
968 #[test]
969 fn delete_unknown() {
970 let dir = tempfile::tempdir().unwrap();
971 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
972 let out = ws.repo_management(Some("nope/none"), true, false, false);
973 assert!(out.contains("Nothing to delete"));
974 }
975
976 #[test]
977 fn iso_round_trip() {
978 let now = SystemTime::now()
979 .duration_since(SystemTime::UNIX_EPOCH)
980 .unwrap()
981 .as_secs();
982 let s = chrono_lite::format_secs(now);
983 let back = chrono_lite::parse_secs(&s).unwrap();
984 assert_eq!(now, back);
985 }
986
987 #[test]
988 fn last_built_sha_round_trip() {
989 let dir = tempfile::tempdir().unwrap();
990 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
991 ws.bump_access("acme/widgets", "cloned");
993 assert_eq!(ws.last_built_sha("acme/widgets"), None);
994 ws.record_built_sha("acme/widgets", "abc1234deadbeef");
995 assert_eq!(
996 ws.last_built_sha("acme/widgets").as_deref(),
997 Some("abc1234deadbeef")
998 );
999 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1001 assert_eq!(
1002 ws2.last_built_sha("acme/widgets").as_deref(),
1003 Some("abc1234deadbeef")
1004 );
1005 }
1006
1007 #[test]
1008 fn inventory_loads_legacy_entries_without_sha_field() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1011 let legacy = r#"{
1013 "old/repo": {
1014 "cloned_at": "2024-01-01T00:00:00",
1015 "last_accessed": "2024-01-01T00:00:00",
1016 "access_count": 5,
1017 "stale": false
1018 }
1019 }"#;
1020 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1021 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1023 assert_eq!(ws2.last_built_sha("old/repo"), None);
1024 let _ = ws;
1025 }
1026
1027 #[test]
1028 fn auto_rebuild_gate_skips_when_sha_matches() {
1029 use std::sync::atomic::{AtomicUsize, Ordering};
1030 let dir = tempfile::tempdir().unwrap();
1031 let calls = Arc::new(AtomicUsize::new(0));
1032 let calls_h = calls.clone();
1033 let hook: PostActivateHook = Arc::new(move |_path, _name| {
1034 calls_h.fetch_add(1, Ordering::SeqCst);
1035 Ok(())
1036 });
1037 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1043 ws.bump_access("acme/widgets", "cloned");
1045 ws.record_built_sha("acme/widgets", "sha_one");
1046 assert_eq!(
1047 ws.last_built_sha("acme/widgets").as_deref(),
1048 Some("sha_one")
1049 );
1050 ws.record_built_sha("acme/widgets", "sha_one");
1053 assert_eq!(
1054 ws.last_built_sha("acme/widgets").as_deref(),
1055 Some("sha_one")
1056 );
1057 assert_eq!(calls.load(Ordering::SeqCst), 0);
1060 }
1061
1062 #[test]
1063 fn local_workspace_binds_root_immediately() {
1064 let dir = tempfile::tempdir().unwrap();
1065 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1066 assert_eq!(ws.kind(), WorkspaceKind::Local);
1067 assert!(ws.active_repo_path().is_some());
1068 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1069 }
1070
1071 #[test]
1072 fn local_workspace_rejects_github_ops() {
1073 let dir = tempfile::tempdir().unwrap();
1074 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1075 let out = ws.repo_management(Some("acme/widgets"), false, false, false);
1076 assert!(out.contains("does not accept a repo name"));
1077 let out = ws.repo_management(None, true, false, false);
1078 assert!(out.contains("does not support `delete`"));
1079 }
1080
1081 #[test]
1082 fn local_workspace_update_rebuilds() {
1083 use std::sync::atomic::{AtomicUsize, Ordering};
1084 let dir = tempfile::tempdir().unwrap();
1085 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1087 let calls = Arc::new(AtomicUsize::new(0));
1088 let calls_h = calls.clone();
1089 let hook: PostActivateHook = Arc::new(move |_p, _n| {
1090 calls_h.fetch_add(1, Ordering::SeqCst);
1091 Ok(())
1092 });
1093 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1094 let _ = ws.repo_management(None, false, true, false);
1096 assert_eq!(calls.load(Ordering::SeqCst), 1);
1097 let out = ws.repo_management(None, false, true, false);
1099 assert_eq!(
1100 calls.load(Ordering::SeqCst),
1101 1,
1102 "auto-rebuild gate must skip"
1103 );
1104 assert!(out.contains("build skipped"));
1105 }
1106
1107 #[test]
1108 fn parses_github_remote_forms() {
1109 assert_eq!(
1110 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1111 Some("kkollsga/kglite")
1112 );
1113 assert_eq!(
1114 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1115 Some("kkollsga/kglite")
1116 );
1117 assert_eq!(
1119 parse_github_remote("https://github.com/acme/widget/").as_deref(),
1120 Some("acme/widget")
1121 );
1122 assert_eq!(
1123 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1124 Some("acme/widget")
1125 );
1126 assert_eq!(
1128 parse_github_remote("https://gitlab.com/acme/widget.git"),
1129 None
1130 );
1131 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1132 assert_eq!(parse_github_remote("not a url"), None);
1133 }
1134
1135 #[test]
1136 fn local_default_github_repo_uses_origin_remote() {
1137 let dir = tempfile::tempdir().unwrap();
1138 let root = dir.path();
1139 let git = |args: &[&str]| {
1142 Command::new("git")
1143 .arg("-C")
1144 .arg(root)
1145 .args(args)
1146 .output()
1147 .unwrap()
1148 };
1149 if !git(&["init"]).status.success() {
1150 return;
1152 }
1153 git(&[
1154 "remote",
1155 "add",
1156 "origin",
1157 "https://github.com/acme/widget.git",
1158 ]);
1159 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1160 assert_eq!(
1161 ws.default_github_repo().as_deref(),
1162 Some("acme/widget"),
1163 "local default repo must come from the origin remote, not the inventory key"
1164 );
1165 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1167 }
1168
1169 #[test]
1170 fn local_default_github_repo_none_without_remote() {
1171 let dir = tempfile::tempdir().unwrap();
1172 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1173 let def = ws.default_github_repo();
1175 assert!(
1176 def.is_none(),
1177 "expected None for a non-git local root, got {def:?}"
1178 );
1179 }
1180
1181 #[test]
1182 fn set_root_dir_only_in_local_mode() {
1183 let dir = tempfile::tempdir().unwrap();
1184 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1185 let out = ws.set_root_dir(dir.path());
1186 assert!(out.contains("only valid in local-workspace"));
1187 }
1188
1189 #[test]
1190 fn update_with_no_active_repo() {
1191 let dir = tempfile::tempdir().unwrap();
1192 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1193 let out = ws.repo_management(None, false, true, false);
1194 assert!(out.contains("No active repository"));
1195 }
1196
1197 #[test]
1198 fn set_root_dir_updates_active_path() {
1199 let dir = tempfile::tempdir().unwrap();
1200 let child = dir.path().join("child");
1201 std::fs::create_dir_all(&child).unwrap();
1202 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1203 let _ = ws.set_root_dir(&child);
1204 assert_eq!(
1205 ws.active_repo_path().unwrap(),
1206 child.canonicalize().unwrap(),
1207 "set_root_dir didn't update active_repo_path"
1208 );
1209 }
1210
1211 #[test]
1212 fn set_root_dir_post_activate_fires_against_new_root() {
1213 let dir = tempfile::tempdir().unwrap();
1214 let child = dir.path().join("child");
1215 std::fs::create_dir_all(&child).unwrap();
1216 std::fs::write(child.join("a.txt"), b"hi").unwrap();
1217 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1218 let seen = seen_path.clone();
1219 let hook: PostActivateHook = Arc::new(move |p, _n| {
1220 *seen.lock().unwrap() = Some(p.to_path_buf());
1221 Ok(())
1222 });
1223 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1224 let _ = ws.set_root_dir(&child);
1225 assert_eq!(
1226 seen_path.lock().unwrap().clone().unwrap(),
1227 child.canonicalize().unwrap(),
1228 "post_activate hook saw the wrong root after set_root_dir"
1229 );
1230 }
1231}