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
79pub type PostActivateRevsHook = Arc<dyn Fn(&Path, &str, &[String]) -> Result<()> + Send + Sync>;
93
94#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
102#[serde(untagged)]
103pub enum RevsRequest {
104 Count(usize),
114 List(Vec<String>),
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120struct InventoryEntry {
121 cloned_at: String,
122 last_accessed: String,
123 #[serde(default)]
124 access_count: u64,
125 #[serde(default)]
126 stale: bool,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
133 last_built_sha: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
147 last_built_revs: Option<RevsRequest>,
148}
149
150pub use crate::server::manifest::WorkspaceKind;
153
154#[derive(Clone)]
156pub struct Workspace {
157 inner: Arc<WorkspaceInner>,
158}
159
160struct WorkspaceInner {
161 kind: WorkspaceKind,
162 workspace_dir: PathBuf,
163 stale_after_days: u32,
164 state: RwLock<WorkspaceState>,
165 post_activate: Option<PostActivateHook>,
166 activation_summary: Option<ActivationSummaryHook>,
169 post_activate_revs: Option<PostActivateRevsHook>,
174}
175
176#[derive(Debug, Default)]
177struct WorkspaceState {
178 active_repo_name: Option<String>,
179 active_repo_path: Option<PathBuf>,
180 active_built_name: Option<String>,
197}
198
199impl Workspace {
200 pub fn open(
202 workspace_dir: PathBuf,
203 stale_after_days: u32,
204 post_activate: Option<PostActivateHook>,
205 ) -> Result<Self> {
206 if !workspace_dir.is_dir() {
207 fs::create_dir_all(&workspace_dir).with_context(|| {
208 format!("failed to create workspace dir {}", workspace_dir.display())
209 })?;
210 }
211 let repos_dir = workspace_dir.join("repos");
212 if !repos_dir.is_dir() {
213 fs::create_dir_all(&repos_dir)
214 .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
215 }
216 let ws = Self {
217 inner: Arc::new(WorkspaceInner {
218 kind: WorkspaceKind::Github,
219 workspace_dir,
220 stale_after_days,
221 state: RwLock::new(WorkspaceState::default()),
222 post_activate,
223 activation_summary: None,
224 post_activate_revs: None,
225 }),
226 };
227 ws.reconcile_inventory()?;
228 Ok(ws)
229 }
230
231 pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
239 if !root.is_dir() {
240 anyhow::bail!(
241 "local workspace root does not exist or is not a directory: {}",
242 root.display()
243 );
244 }
245 let canon_root = root
246 .canonicalize()
247 .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
248 let inv_dir = canon_root.join(".mcp-workspace");
251 if !inv_dir.is_dir() {
252 fs::create_dir_all(&inv_dir).with_context(|| {
253 format!("failed to create local-workspace dir {}", inv_dir.display())
254 })?;
255 }
256 let mut state = WorkspaceState::default();
257 let synthetic_name = synthesize_local_name(&canon_root);
258 state.active_repo_name = Some(synthetic_name);
259 state.active_repo_path = Some(canon_root.clone());
260 Ok(Self {
261 inner: Arc::new(WorkspaceInner {
262 kind: WorkspaceKind::Local,
263 workspace_dir: canon_root,
264 stale_after_days: u32::MAX, state: RwLock::new(state),
266 post_activate,
267 activation_summary: None,
268 post_activate_revs: None,
269 }),
270 })
271 }
272
273 pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
279 match Arc::get_mut(&mut self.inner) {
280 Some(inner) => inner.activation_summary = Some(hook),
281 None => tracing::warn!(
282 "with_activation_summary called after the workspace was cloned; summary not attached"
283 ),
284 }
285 self
286 }
287
288 pub fn with_post_activate_revs(mut self, hook: PostActivateRevsHook) -> Self {
296 match Arc::get_mut(&mut self.inner) {
297 Some(inner) => inner.post_activate_revs = Some(hook),
298 None => tracing::warn!(
299 "with_post_activate_revs called after the workspace was cloned; revs hook not attached"
300 ),
301 }
302 self
303 }
304
305 pub fn kind(&self) -> WorkspaceKind {
306 self.inner.kind
307 }
308
309 pub fn workspace_dir(&self) -> &Path {
310 &self.inner.workspace_dir
311 }
312
313 pub fn repos_dir(&self) -> PathBuf {
314 self.inner.workspace_dir.join("repos")
315 }
316
317 fn inventory_path(&self) -> PathBuf {
318 match self.inner.kind {
319 WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
320 WorkspaceKind::Local => self
321 .inner
322 .workspace_dir
323 .join(".mcp-workspace")
324 .join("inventory.json"),
325 }
326 }
327
328 pub fn active_repo_name(&self) -> Option<String> {
330 self.inner.state.read().unwrap().active_repo_name.clone()
331 }
332
333 pub fn active_repo_path(&self) -> Option<PathBuf> {
335 self.inner.state.read().unwrap().active_repo_path.clone()
336 }
337
338 pub fn default_github_repo(&self) -> Option<String> {
347 match self.inner.kind {
348 WorkspaceKind::Github => self.active_repo_name(),
349 WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
350 }
351 }
352
353 fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
358 let path = self.inventory_path();
359 let Ok(text) = fs::read_to_string(&path) else {
360 return BTreeMap::new();
361 };
362 serde_json::from_str(&text).unwrap_or_default()
363 }
364
365 fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
366 let path = self.inventory_path();
367 let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
368 fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
369 Ok(())
370 }
371
372 fn reconcile_inventory(&self) -> Result<()> {
373 let mut inv = self.load_inventory();
374 let mut on_disk: Vec<String> = Vec::new();
375 if self.repos_dir().is_dir() {
376 for org_entry in fs::read_dir(self.repos_dir())? {
377 let Ok(org_entry) = org_entry else { continue };
378 if !org_entry.path().is_dir() {
379 continue;
380 }
381 let org = org_entry.file_name().to_string_lossy().into_owned();
382 if org.starts_with('.') {
383 continue;
384 }
385 for repo_entry in fs::read_dir(org_entry.path())? {
386 let Ok(repo_entry) = repo_entry else { continue };
387 if !repo_entry.path().is_dir() {
388 continue;
389 }
390 let repo = repo_entry.file_name().to_string_lossy().into_owned();
391 if repo.starts_with('.') {
392 continue;
393 }
394 let rname = format!("{org}/{repo}");
395 on_disk.push(rname.clone());
396 inv.entry(rname).or_insert_with(|| {
397 let mtime = repo_entry
398 .metadata()
399 .ok()
400 .and_then(|m| m.modified().ok())
401 .map(format_iso)
402 .unwrap_or_else(now_iso);
403 InventoryEntry {
404 cloned_at: mtime.clone(),
405 last_accessed: mtime,
406 access_count: 0,
407 stale: false,
408 last_built_sha: None,
409 last_built_revs: None,
410 }
411 });
412 }
413 }
414 }
415 for (rname, entry) in inv.iter_mut() {
416 if !on_disk.contains(rname) && !entry.stale {
417 entry.stale = true;
418 }
419 }
420 self.save_inventory(&inv)?;
421 Ok(())
422 }
423
424 fn bump_access(&self, name: &str, action: &str) {
425 let mut inv = self.load_inventory();
426 let now = now_iso();
427 let entry = inv
428 .entry(name.to_string())
429 .or_insert_with(|| InventoryEntry {
430 cloned_at: now.clone(),
431 last_accessed: now.clone(),
432 access_count: 0,
433 stale: false,
434 last_built_sha: None,
435 last_built_revs: None,
436 });
437 entry.last_accessed = now.clone();
438 entry.access_count += 1;
439 entry.stale = false;
440 if action == "cloned" || entry.cloned_at.is_empty() {
441 entry.cloned_at = now;
442 }
443 let _ = self.save_inventory(&inv);
444 }
445
446 fn mark_stale(&self, name: &str) {
447 let mut inv = self.load_inventory();
448 if let Some(entry) = inv.get_mut(name) {
449 entry.stale = true;
450 let _ = self.save_inventory(&inv);
451 }
452 }
453
454 fn sweep_stale(&self) -> Vec<String> {
455 if matches!(self.inner.kind, WorkspaceKind::Local) {
457 return Vec::new();
458 }
459 let mut inv = self.load_inventory();
460 let cutoff = SystemTime::now()
461 - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
462 let active = self.active_repo_name();
463 let mut swept: Vec<String> = Vec::new();
464 for (rname, entry) in inv.iter_mut() {
465 if entry.stale {
466 continue;
467 }
468 if Some(rname.as_str()) == active.as_deref() {
469 continue;
470 }
471 let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
472 if last >= cutoff {
473 continue;
474 }
475 let parts: Vec<&str> = rname.splitn(2, '/').collect();
476 if parts.len() != 2 {
477 continue;
478 }
479 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
480 if repo_path.exists() {
481 let _ = fs::remove_dir_all(&repo_path);
482 }
483 entry.stale = true;
484 swept.push(rname.clone());
485 }
486 if !swept.is_empty() {
487 let _ = self.save_inventory(&inv);
488 self.prune_empty_org_dirs();
489 }
490 swept
491 }
492
493 fn prune_empty_org_dirs(&self) {
494 let Ok(entries) = fs::read_dir(self.repos_dir()) else {
495 return;
496 };
497 for entry in entries.flatten() {
498 let path = entry.path();
499 if !path.is_dir() {
500 continue;
501 }
502 if let Ok(children) = fs::read_dir(&path) {
503 let real: Vec<_> = children
504 .flatten()
505 .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
506 .collect();
507 if real.is_empty() {
508 let _ = fs::remove_dir_all(&path);
509 }
510 }
511 }
512 }
513
514 fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
525 if matches!(self.inner.kind, WorkspaceKind::Local) {
526 let root = self
536 .inner
537 .state
538 .read()
539 .unwrap()
540 .active_repo_path
541 .clone()
542 .unwrap_or_else(|| self.inner.workspace_dir.clone());
543 let prev_sha = self.last_built_sha(name);
544 let fingerprint = fingerprint_dir(&root);
545 let action = match prev_sha {
546 Some(p) if p == fingerprint => "current",
547 None => "cloned", Some(_) => "updated",
549 };
550 return Ok((action.to_string(), root, fingerprint));
551 }
552 let parts: Vec<&str> = name.splitn(2, '/').collect();
553 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
554 if !repo_path.exists() {
555 fs::create_dir_all(repo_path.parent().unwrap()).ok();
556 let url = format!("https://github.com/{name}.git");
557 let out = Command::new("git")
565 .args([
566 "clone",
567 "--filter=tree:0",
568 "--tags",
569 &url,
570 repo_path.to_str().unwrap(),
571 ])
572 .output()
573 .context("failed to spawn `git clone`")?;
574 if !out.status.success() {
575 anyhow::bail!(
576 "git clone failed: {}",
577 String::from_utf8_lossy(&out.stderr).trim()
578 );
579 }
580 let sha = git_rev_parse(&repo_path, "HEAD")?;
581 return Ok(("cloned".to_string(), repo_path, sha));
582 }
583
584 Command::new("git")
590 .args(["fetch", "origin", "--tags"])
591 .current_dir(&repo_path)
592 .output()
593 .context("git fetch failed")?;
594 let local = git_rev_parse(&repo_path, "HEAD")?;
595 let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
596 if local != remote {
597 Command::new("git")
598 .args(["reset", "--hard", "FETCH_HEAD"])
599 .current_dir(&repo_path)
600 .output()
601 .context("git reset failed")?;
602 let sha = git_rev_parse(&repo_path, "HEAD")?;
603 return Ok(("updated".to_string(), repo_path, sha));
604 }
605 Ok(("current".to_string(), repo_path, local))
606 }
607
608 fn resolve_revs(&self, repo_path: &Path, req: &RevsRequest) -> Result<Vec<String>> {
641 let resolved = match req {
642 RevsRequest::Count(n) => {
643 let out = Command::new("git")
644 .args(["tag", "--sort=-v:refname"])
645 .current_dir(repo_path)
646 .output()
647 .context("failed to spawn `git tag`")?;
648 if !out.status.success() {
649 anyhow::bail!(
650 "cannot resolve revs: `git tag` failed in {} (is it a git repo?): {}",
651 repo_path.display(),
652 String::from_utf8_lossy(&out.stderr).trim()
653 );
654 }
655 let tags: Vec<String> = String::from_utf8_lossy(&out.stdout)
656 .lines()
657 .map(|l| l.trim().to_string())
658 .filter(|l| !l.is_empty())
659 .collect();
660 if tags.is_empty() {
661 anyhow::bail!(
662 "revs={n} requested but '{}' has no tags to resolve",
663 repo_path.display()
664 );
665 }
666 let mut chosen = select_family_tags(&tags, *n).unwrap_or_else(|| {
670 let mut raw: Vec<String> = tags.into_iter().take(*n).collect();
671 raw.reverse();
672 raw
673 });
674 chosen.push("HEAD".to_string());
677 chosen
678 }
679 RevsRequest::List(revs) => {
680 if revs.is_empty() {
681 anyhow::bail!("revs list is empty — pass at least one revision");
682 }
683 for r in revs {
684 let out = Command::new("git")
685 .args([
686 "rev-parse",
687 "--verify",
688 "--quiet",
689 &format!("{r}^{{commit}}"),
690 ])
691 .current_dir(repo_path)
692 .output()
693 .context("failed to spawn `git rev-parse`")?;
694 if !out.status.success() {
695 anyhow::bail!("revision '{r}' does not exist in '{}'", repo_path.display());
696 }
697 }
698 revs.clone()
699 }
700 };
701 Ok(dedup_labels(resolved))
702 }
703
704 fn activate(
723 &self,
724 name: &str,
725 force_rebuild: bool,
726 revs: Option<&RevsRequest>,
727 ) -> Result<String> {
728 let prev_built_sha = self.last_built_sha(name);
729 let prev_built_revs = self.last_built_revs(name);
730 let (action, repo_path, head_sha) = self.clone_or_update(name)?;
731 let resolved_revs = match revs {
735 Some(req) => Some(self.resolve_revs(&repo_path, req)?),
736 None => None,
737 };
738 self.bump_access(name, &action);
739 let is_active_built = {
740 let mut state = self.inner.state.write().unwrap();
741 state.active_repo_name = Some(name.to_string());
742 state.active_repo_path = Some(repo_path.clone());
743 state.active_built_name.as_deref() == Some(name)
744 };
745
746 let already_built = !force_rebuild
774 && resolved_revs.is_none()
775 && prev_built_revs.is_none()
776 && action == "current"
777 && prev_built_sha.as_deref() == Some(head_sha.as_str())
778 && is_active_built;
779 let mut hook_skipped = false;
780 let mut revs_hook_ran = false;
783 let hook_ok = if already_built {
784 hook_skipped = true;
785 true
786 } else if let (Some(resolved), Some(revs_hook)) =
787 (resolved_revs.as_ref(), &self.inner.post_activate_revs)
788 {
789 revs_hook_ran = true;
790 match revs_hook(&repo_path, name, resolved) {
791 Ok(()) => true,
792 Err(e) => {
793 tracing::warn!("post-activate revs hook for {name} failed: {e}");
794 false
795 }
796 }
797 } else if let Some(hook) = &self.inner.post_activate {
798 match hook(&repo_path, name) {
801 Ok(()) => true,
802 Err(e) => {
803 tracing::warn!("post-activate hook for {name} failed: {e}");
804 false
805 }
806 }
807 } else {
808 true
811 };
812 if hook_ok {
813 let built_revs = if revs_hook_ran { revs } else { None };
818 self.record_built(name, &head_sha, built_revs);
819 }
820 if hook_ok && !hook_skipped {
828 self.inner.state.write().unwrap().active_built_name = Some(name.to_string());
829 }
830 let verb = match action.as_str() {
831 "cloned" => "Cloned",
832 "updated" => "Updated",
833 "current" => "Activated (already up to date)",
834 other => other,
835 };
836 let suffix = if hook_skipped {
837 " [build skipped: HEAD matches last-built SHA]"
838 } else {
839 ""
840 };
841 let mut base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
842 if revs_hook_ran && hook_ok {
847 if let Some(resolved) = &resolved_revs {
848 base.push_str(&format!("\nrevs: {}", resolved.join(", ")));
849 }
850 }
851 let summary = if hook_ok {
857 self.inner
858 .activation_summary
859 .as_ref()
860 .and_then(|h| h(&repo_path, name))
861 } else {
862 None
863 };
864 Ok(match summary {
865 Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
866 _ => base,
867 })
868 }
869
870 fn record_built(&self, name: &str, sha: &str, revs: Option<&RevsRequest>) {
876 let mut inv = self.load_inventory();
877 if let Some(entry) = inv.get_mut(name) {
878 entry.last_built_sha = Some(sha.to_string());
879 entry.last_built_revs = revs.cloned();
880 let _ = self.save_inventory(&inv);
881 }
882 }
883
884 pub fn last_built_sha(&self, name: &str) -> Option<String> {
889 self.load_inventory()
890 .get(name)
891 .and_then(|e| e.last_built_sha.clone())
892 }
893
894 pub fn last_built_revs(&self, name: &str) -> Option<RevsRequest> {
899 self.load_inventory()
900 .get(name)
901 .and_then(|e| e.last_built_revs.clone())
902 }
903
904 fn delete(&self, name: &str) -> Result<String> {
905 let parts: Vec<&str> = name.splitn(2, '/').collect();
906 if parts.len() != 2 {
907 anyhow::bail!("Invalid repo name");
908 }
909 let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
910 let mut deleted = Vec::new();
911 if repo_path.exists() {
912 fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
913 deleted.push("repo");
914 }
915 self.mark_stale(name);
916 self.prune_empty_org_dirs();
917 if deleted.is_empty() {
918 return Ok(format!("Nothing to delete — '{name}' not found."));
919 }
920 let mut state = self.inner.state.write().unwrap();
921 if state.active_repo_name.as_deref() == Some(name) {
922 state.active_repo_name = None;
923 state.active_repo_path = None;
924 return Ok(format!(
925 "Deleted {}. Active repo cleared.",
926 deleted.join(", ")
927 ));
928 }
929 Ok(format!("Deleted {}.", deleted.join(", ")))
930 }
931
932 fn list(&self) -> String {
933 let inv = self.load_inventory();
934 if inv.is_empty() {
935 return "No repos cloned yet. Call repo_management('org/repo') to clone one."
936 .to_string();
937 }
938 let active = self.active_repo_name();
939 let mut live: Vec<String> = Vec::new();
940 let mut stale_lines: Vec<String> = Vec::new();
941 for (rname, entry) in &inv {
942 let marker = if Some(rname.as_str()) == active.as_deref() {
943 " [active]"
944 } else {
945 ""
946 };
947 let access = format!(
948 "{} access{}, last {}",
949 entry.access_count,
950 if entry.access_count == 1 { "" } else { "es" },
951 relative_time(&entry.last_accessed)
952 );
953 if entry.stale {
954 stale_lines.push(format!(
955 " {rname} [STALE — re-fetch with repo_management('{rname}')] ({access})"
956 ));
957 } else {
958 live.push(format!(" {rname}{marker} ({access})"));
959 }
960 }
961 let mut out = String::new();
962 if !live.is_empty() {
963 out.push_str(&format!(
964 "{} live repo(s):\n{}",
965 live.len(),
966 live.join("\n")
967 ));
968 }
969 if !stale_lines.is_empty() {
970 if !out.is_empty() {
971 out.push_str("\n\n");
972 }
973 out.push_str(&format!(
974 "{} stale repo(s):\n{}",
975 stale_lines.len(),
976 stale_lines.join("\n")
977 ));
978 }
979 out
980 }
981
982 pub fn repo_management(
996 &self,
997 name: Option<&str>,
998 delete: bool,
999 update: bool,
1000 force_rebuild: bool,
1001 revs: Option<&RevsRequest>,
1002 ) -> String {
1003 if matches!(self.inner.kind, WorkspaceKind::Local) {
1005 if name.is_some() {
1006 return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
1007 to switch the active root, or pass `update=true` / `force_rebuild=true` \
1008 to rebuild against the current root."
1009 .to_string();
1010 }
1011 if delete {
1012 return "Local-workspace mode does not support `delete`. The root is owned by the \
1013 operator; remove it manually."
1014 .to_string();
1015 }
1016 let active = match self.active_repo_name() {
1017 Some(n) => n,
1018 None => return "No active local root.".to_string(),
1019 };
1020 let _ = update; let effective = match revs {
1033 Some(r) => Some(r.clone()),
1034 None => self.last_built_revs(&active),
1035 };
1036 return self
1037 .activate(&active, force_rebuild, effective.as_ref())
1038 .unwrap_or_else(|e| format!("rebuild failed: {e}"));
1039 }
1040
1041 let swept = self.sweep_stale();
1042 let prefix = if swept.is_empty() {
1043 String::new()
1044 } else {
1045 format!(
1046 "[Swept {} idle repo(s) (>{}d): {}]\n\n",
1047 swept.len(),
1048 self.inner.stale_after_days,
1049 swept.join(", ")
1050 )
1051 };
1052
1053 if name.is_none() && !update {
1054 return prefix + &self.list();
1055 }
1056
1057 if update {
1058 let Some(active) = self.active_repo_name() else {
1059 return prefix + "No active repository. Call repo_management('org/repo') first.";
1060 };
1061 let effective = match revs {
1069 Some(r) => Some(r.clone()),
1070 None => self.last_built_revs(&active),
1071 };
1072 return prefix
1073 + &self
1074 .activate(&active, force_rebuild, effective.as_ref())
1075 .unwrap_or_else(|e| format!("update failed: {e}"));
1076 }
1077
1078 let Some(name) = name else {
1079 return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
1080 };
1081 if let Err(e) = validate_repo_name(name) {
1082 return prefix + &e.to_string();
1083 }
1084 if delete {
1085 return prefix
1086 + &self
1087 .delete(name)
1088 .unwrap_or_else(|e| format!("delete failed: {e}"));
1089 }
1090 prefix
1091 + &self
1092 .activate(name, force_rebuild, revs)
1093 .unwrap_or_else(|e| format!("activate failed: {e}"))
1094 }
1095
1096 pub fn set_root_dir(&self, new_root: &Path, revs: Option<&RevsRequest>) -> String {
1103 if !matches!(self.inner.kind, WorkspaceKind::Local) {
1104 return "set_root_dir is only valid in local-workspace mode.".to_string();
1105 }
1106 if !new_root.is_dir() {
1107 return format!(
1108 "Path does not exist or is not a directory: {}",
1109 new_root.display()
1110 );
1111 }
1112 let canon = match new_root.canonicalize() {
1113 Ok(p) => p,
1114 Err(e) => return format!("canonicalize failed: {e}"),
1115 };
1116 let synthetic = synthesize_local_name(&canon);
1117 {
1118 let mut state = self.inner.state.write().unwrap();
1119 state.active_repo_name = Some(synthetic.clone());
1120 state.active_repo_path = Some(canon.clone());
1121 }
1122 self.activate(&synthetic, false, revs)
1126 .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
1127 }
1128}
1129
1130fn dedup_labels(revs: Vec<String>) -> Vec<String> {
1137 let mut seen = std::collections::HashSet::new();
1138 revs.into_iter()
1139 .filter(|r| seen.insert(r.clone()))
1140 .collect()
1141}
1142
1143const PRERELEASE_MARKERS: &[&str] = &["rc", "alpha", "beta", "dev", "pre", "preview"];
1148
1149#[derive(Debug, Clone, PartialEq, Eq)]
1153struct ClassifiedTag {
1154 raw: String,
1155 prefix: String,
1156 version: Vec<u64>,
1157 is_prerelease: bool,
1158}
1159
1160fn classify_tag(tag: &str) -> Option<ClassifiedTag> {
1174 let bytes = tag.as_bytes();
1175 for i in 0..bytes.len() {
1176 if !bytes[i].is_ascii_digit() {
1177 continue;
1178 }
1179 if i > 0 && bytes[i - 1].is_ascii_digit() {
1181 continue;
1182 }
1183 if let Some((version, is_prerelease)) = parse_version_at(&tag[i..]) {
1184 return Some(ClassifiedTag {
1185 raw: tag.to_string(),
1186 prefix: tag[..i].to_string(),
1187 version,
1188 is_prerelease,
1189 });
1190 }
1191 }
1192 None
1193}
1194
1195fn parse_version_at(s: &str) -> Option<(Vec<u64>, bool)> {
1201 let bytes = s.as_bytes();
1202 let mut nums: Vec<u64> = Vec::new();
1203 let mut idx = 0usize;
1204 loop {
1205 let start = idx;
1206 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
1207 idx += 1;
1208 }
1209 if idx == start {
1210 return None; }
1212 nums.push(s[start..idx].parse().ok()?);
1213 if idx + 1 < bytes.len() && bytes[idx] == b'.' && bytes[idx + 1].is_ascii_digit() {
1215 idx += 1;
1216 continue;
1217 }
1218 break;
1219 }
1220 let rest = &s[idx..];
1221 if rest.is_empty() {
1222 return Some((nums, false));
1223 }
1224 let after_sep = rest
1226 .strip_prefix(|c| c == '-' || c == '.' || c == '_')
1227 .unwrap_or(rest);
1228 let lower = after_sep.to_ascii_lowercase();
1229 if PRERELEASE_MARKERS.iter().any(|m| lower.starts_with(m)) {
1230 Some((nums, true))
1231 } else {
1232 None
1233 }
1234}
1235
1236fn select_family_tags(tags: &[String], n: usize) -> Option<Vec<String>> {
1252 let classified: Vec<ClassifiedTag> = tags.iter().filter_map(|t| classify_tag(t)).collect();
1253 if classified.is_empty() {
1254 return None;
1255 }
1256 let mut families: BTreeMap<String, Vec<&ClassifiedTag>> = BTreeMap::new();
1258 for c in &classified {
1259 families.entry(c.prefix.clone()).or_default().push(c);
1260 }
1261 let stable_count = |v: &Vec<&ClassifiedTag>| v.iter().filter(|c| !c.is_prerelease).count();
1262 let any_stable = families.values().any(|v| stable_count(v) > 0);
1263 let chosen = families.values().max_by(|a, b| {
1268 if any_stable {
1269 stable_count(a).cmp(&stable_count(b))
1270 } else {
1271 a.len().cmp(&b.len())
1272 }
1273 })?;
1274 let mut pool: Vec<&ClassifiedTag> = if any_stable {
1275 chosen
1276 .iter()
1277 .copied()
1278 .filter(|c| !c.is_prerelease)
1279 .collect()
1280 } else {
1281 chosen.to_vec()
1282 };
1283 pool.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| b.raw.cmp(&a.raw)));
1286 let mut newest: Vec<String> = pool.into_iter().take(n).map(|c| c.raw.clone()).collect();
1287 newest.reverse(); Some(newest)
1289}
1290
1291fn synthesize_local_name(root: &Path) -> String {
1295 let name = root
1296 .file_name()
1297 .map(|s| s.to_string_lossy().into_owned())
1298 .unwrap_or_else(|| "local".to_string());
1299 format!("local/{name}")
1300}
1301
1302fn parse_origin_repo(root: &Path) -> Option<String> {
1313 let out = Command::new("git")
1314 .arg("-C")
1315 .arg(root)
1316 .args(["remote", "get-url", "origin"])
1317 .output()
1318 .ok()?;
1319 if !out.status.success() {
1320 return None;
1321 }
1322 let url = String::from_utf8(out.stdout).ok()?;
1323 parse_github_remote(url.trim())
1324}
1325
1326fn parse_github_remote(url: &str) -> Option<String> {
1329 let path = url
1333 .strip_prefix("git@github.com:")
1334 .or_else(|| url.strip_prefix("https://github.com/"))
1335 .or_else(|| url.strip_prefix("http://github.com/"))
1336 .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
1337 let path = path.strip_suffix(".git").unwrap_or(path);
1338 let path = path.trim_end_matches('/');
1339 let mut parts = path.split('/');
1341 let org = parts.next().filter(|s| !s.is_empty())?;
1342 let repo = parts.next().filter(|s| !s.is_empty())?;
1343 if parts.next().is_some() {
1344 return None;
1345 }
1346 Some(format!("{org}/{repo}"))
1347}
1348
1349fn fingerprint_dir(root: &Path) -> String {
1354 use std::hash::{Hash, Hasher};
1355 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1356 let walker = ignore::WalkBuilder::new(root)
1357 .standard_filters(true)
1358 .hidden(true)
1359 .git_ignore(true)
1360 .build();
1361 for entry in walker.flatten() {
1362 if !entry.path().is_file() {
1363 continue;
1364 }
1365 let Ok(meta) = entry.metadata() else { continue };
1366 let mtime = meta
1367 .modified()
1368 .ok()
1369 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
1370 .map(|d| d.as_secs())
1371 .unwrap_or(0);
1372 entry.path().to_string_lossy().hash(&mut hasher);
1373 mtime.hash(&mut hasher);
1374 meta.len().hash(&mut hasher);
1375 }
1376 format!("local-{:016x}", hasher.finish())
1377}
1378
1379fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
1380 let out = Command::new("git")
1381 .args(["rev-parse", refspec])
1382 .current_dir(repo_path)
1383 .output()
1384 .context("git rev-parse failed")?;
1385 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1386}
1387
1388fn now_iso() -> String {
1389 format_iso(SystemTime::now())
1390}
1391
1392fn format_iso(t: SystemTime) -> String {
1393 let secs = t
1394 .duration_since(SystemTime::UNIX_EPOCH)
1395 .map(|d| d.as_secs())
1396 .unwrap_or(0);
1397 chrono_lite::format_secs(secs)
1399}
1400
1401fn parse_iso(s: &str) -> Option<SystemTime> {
1402 let secs = chrono_lite::parse_secs(s)?;
1403 SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
1404}
1405
1406fn relative_time(iso: &str) -> String {
1407 let Some(t) = parse_iso(iso) else {
1408 return "unknown".to_string();
1409 };
1410 let now = SystemTime::now();
1411 let delta = now.duration_since(t).unwrap_or_default().as_secs();
1412 if delta < 3600 {
1413 "just now".to_string()
1414 } else if delta < 86_400 {
1415 format!("{}h ago", delta / 3600)
1416 } else {
1417 format!("{}d ago", delta / 86_400)
1418 }
1419}
1420
1421mod chrono_lite {
1424 pub fn format_secs(secs: u64) -> String {
1425 let days = (secs / 86_400) as i64;
1427 let time = secs % 86_400;
1428 let (y, mo, d) = days_to_civil(days + 719_468);
1429 let h = time / 3600;
1430 let m = (time / 60) % 60;
1431 let s = time % 60;
1432 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
1433 }
1434
1435 pub fn parse_secs(s: &str) -> Option<u64> {
1436 let bytes = s.as_bytes();
1439 if bytes.len() < 19 {
1440 return None;
1441 }
1442 let y: i64 = s.get(0..4)?.parse().ok()?;
1443 let mo: u32 = s.get(5..7)?.parse().ok()?;
1444 let d: u32 = s.get(8..10)?.parse().ok()?;
1445 let h: u64 = s.get(11..13)?.parse().ok()?;
1446 let m: u64 = s.get(14..16)?.parse().ok()?;
1447 let sc: u64 = s.get(17..19)?.parse().ok()?;
1448 let days = civil_to_days(y, mo, d) - 719_468;
1449 Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
1450 }
1451
1452 fn days_to_civil(z: i64) -> (i64, u32, u32) {
1453 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
1454 let doe = (z - era * 146_097) as u64;
1455 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
1456 let y = (yoe as i64) + era * 400;
1457 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1458 let mp = (5 * doy + 2) / 153;
1459 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
1460 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
1461 let y = if m <= 2 { y + 1 } else { y };
1462 (y, m, d)
1463 }
1464
1465 fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1466 let y = if m <= 2 { y - 1 } else { y };
1467 let era = if y >= 0 { y } else { y - 399 } / 400;
1468 let yoe = (y - era * 400) as u64;
1469 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1470 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1471 era * 146_097 + doe as i64
1472 }
1473}
1474
1475#[allow(dead_code)]
1477fn _json_keepalive() {
1478 let _ = json!({});
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483 use super::*;
1484
1485 #[test]
1486 fn validates_repo_names() {
1487 assert!(validate_repo_name("pydata/xarray").is_ok());
1488 assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1489 assert!(validate_repo_name("xarray").is_err());
1490 assert!(validate_repo_name("a/b/c").is_err());
1491 assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1492 }
1493
1494 #[test]
1495 fn open_creates_layout() {
1496 let dir = tempfile::tempdir().unwrap();
1497 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1498 assert!(ws.repos_dir().is_dir());
1499 }
1500
1501 #[test]
1502 fn empty_list() {
1503 let dir = tempfile::tempdir().unwrap();
1504 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1505 let out = ws.repo_management(None, false, false, false, None);
1506 assert!(out.contains("No repos cloned yet"));
1507 }
1508
1509 #[test]
1510 fn invalid_repo_name_rejected() {
1511 let dir = tempfile::tempdir().unwrap();
1512 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1513 let out = ws.repo_management(Some("bad name with spaces"), false, false, false, None);
1514 assert!(out.contains("Invalid repo name"));
1515 }
1516
1517 #[test]
1518 fn delete_unknown() {
1519 let dir = tempfile::tempdir().unwrap();
1520 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1521 let out = ws.repo_management(Some("nope/none"), true, false, false, None);
1522 assert!(out.contains("Nothing to delete"));
1523 }
1524
1525 #[test]
1526 fn iso_round_trip() {
1527 let now = SystemTime::now()
1528 .duration_since(SystemTime::UNIX_EPOCH)
1529 .unwrap()
1530 .as_secs();
1531 let s = chrono_lite::format_secs(now);
1532 let back = chrono_lite::parse_secs(&s).unwrap();
1533 assert_eq!(now, back);
1534 }
1535
1536 #[test]
1537 fn last_built_sha_round_trip() {
1538 let dir = tempfile::tempdir().unwrap();
1539 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1540 ws.bump_access("acme/widgets", "cloned");
1542 assert_eq!(ws.last_built_sha("acme/widgets"), None);
1543 ws.record_built("acme/widgets", "abc1234deadbeef", None);
1544 assert_eq!(
1545 ws.last_built_sha("acme/widgets").as_deref(),
1546 Some("abc1234deadbeef")
1547 );
1548 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1550 assert_eq!(
1551 ws2.last_built_sha("acme/widgets").as_deref(),
1552 Some("abc1234deadbeef")
1553 );
1554 }
1555
1556 #[test]
1557 fn inventory_loads_legacy_entries_without_sha_field() {
1558 let dir = tempfile::tempdir().unwrap();
1559 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1560 let legacy = r#"{
1562 "old/repo": {
1563 "cloned_at": "2024-01-01T00:00:00",
1564 "last_accessed": "2024-01-01T00:00:00",
1565 "access_count": 5,
1566 "stale": false
1567 }
1568 }"#;
1569 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1570 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1572 assert_eq!(ws2.last_built_sha("old/repo"), None);
1573 let _ = ws;
1574 }
1575
1576 #[test]
1577 fn auto_rebuild_gate_skips_when_sha_matches() {
1578 use std::sync::atomic::{AtomicUsize, Ordering};
1579 let dir = tempfile::tempdir().unwrap();
1580 let calls = Arc::new(AtomicUsize::new(0));
1581 let calls_h = calls.clone();
1582 let hook: PostActivateHook = Arc::new(move |_path, _name| {
1583 calls_h.fetch_add(1, Ordering::SeqCst);
1584 Ok(())
1585 });
1586 let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1592 ws.bump_access("acme/widgets", "cloned");
1594 ws.record_built("acme/widgets", "sha_one", None);
1595 assert_eq!(
1596 ws.last_built_sha("acme/widgets").as_deref(),
1597 Some("sha_one")
1598 );
1599 ws.record_built("acme/widgets", "sha_one", None);
1602 assert_eq!(
1603 ws.last_built_sha("acme/widgets").as_deref(),
1604 Some("sha_one")
1605 );
1606 assert_eq!(calls.load(Ordering::SeqCst), 0);
1609 }
1610
1611 #[test]
1612 fn local_workspace_binds_root_immediately() {
1613 let dir = tempfile::tempdir().unwrap();
1614 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1615 assert_eq!(ws.kind(), WorkspaceKind::Local);
1616 assert!(ws.active_repo_path().is_some());
1617 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1618 }
1619
1620 #[test]
1621 fn local_workspace_rejects_github_ops() {
1622 let dir = tempfile::tempdir().unwrap();
1623 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1624 let out = ws.repo_management(Some("acme/widgets"), false, false, false, None);
1625 assert!(out.contains("does not accept a repo name"));
1626 let out = ws.repo_management(None, true, false, false, None);
1627 assert!(out.contains("does not support `delete`"));
1628 }
1629
1630 #[test]
1631 fn local_workspace_update_rebuilds() {
1632 use std::sync::atomic::{AtomicUsize, Ordering};
1633 let dir = tempfile::tempdir().unwrap();
1634 std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1636 let calls = Arc::new(AtomicUsize::new(0));
1637 let calls_h = calls.clone();
1638 let hook: PostActivateHook = Arc::new(move |_p, _n| {
1639 calls_h.fetch_add(1, Ordering::SeqCst);
1640 Ok(())
1641 });
1642 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1643 let _ = ws.repo_management(None, false, true, false, None);
1645 assert_eq!(calls.load(Ordering::SeqCst), 1);
1646 let out = ws.repo_management(None, false, true, false, None);
1648 assert_eq!(
1649 calls.load(Ordering::SeqCst),
1650 1,
1651 "auto-rebuild gate must skip"
1652 );
1653 assert!(out.contains("build skipped"));
1654 }
1655
1656 #[test]
1657 fn parses_github_remote_forms() {
1658 assert_eq!(
1659 parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1660 Some("kkollsga/kglite")
1661 );
1662 assert_eq!(
1663 parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1664 Some("kkollsga/kglite")
1665 );
1666 assert_eq!(
1668 parse_github_remote("https://github.com/acme/widget/").as_deref(),
1669 Some("acme/widget")
1670 );
1671 assert_eq!(
1672 parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1673 Some("acme/widget")
1674 );
1675 assert_eq!(
1677 parse_github_remote("https://gitlab.com/acme/widget.git"),
1678 None
1679 );
1680 assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1681 assert_eq!(parse_github_remote("not a url"), None);
1682 }
1683
1684 #[test]
1685 fn local_default_github_repo_uses_origin_remote() {
1686 let dir = tempfile::tempdir().unwrap();
1687 let root = dir.path();
1688 let git = |args: &[&str]| {
1691 Command::new("git")
1692 .arg("-C")
1693 .arg(root)
1694 .args(args)
1695 .output()
1696 .unwrap()
1697 };
1698 if !git(&["init"]).status.success() {
1699 return;
1701 }
1702 git(&[
1703 "remote",
1704 "add",
1705 "origin",
1706 "https://github.com/acme/widget.git",
1707 ]);
1708 let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1709 assert_eq!(
1710 ws.default_github_repo().as_deref(),
1711 Some("acme/widget"),
1712 "local default repo must come from the origin remote, not the inventory key"
1713 );
1714 assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1716 }
1717
1718 #[test]
1719 fn local_default_github_repo_none_without_remote() {
1720 let dir = tempfile::tempdir().unwrap();
1721 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1722 let def = ws.default_github_repo();
1724 assert!(
1725 def.is_none(),
1726 "expected None for a non-git local root, got {def:?}"
1727 );
1728 }
1729
1730 #[test]
1731 fn set_root_dir_only_in_local_mode() {
1732 let dir = tempfile::tempdir().unwrap();
1733 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1734 let out = ws.set_root_dir(dir.path(), None);
1735 assert!(out.contains("only valid in local-workspace"));
1736 }
1737
1738 #[test]
1739 fn update_with_no_active_repo() {
1740 let dir = tempfile::tempdir().unwrap();
1741 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1742 let out = ws.repo_management(None, false, true, false, None);
1743 assert!(out.contains("No active repository"));
1744 }
1745
1746 #[test]
1747 fn set_root_dir_updates_active_path() {
1748 let dir = tempfile::tempdir().unwrap();
1749 let child = dir.path().join("child");
1750 std::fs::create_dir_all(&child).unwrap();
1751 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1752 let _ = ws.set_root_dir(&child, None);
1753 assert_eq!(
1754 ws.active_repo_path().unwrap(),
1755 child.canonicalize().unwrap(),
1756 "set_root_dir didn't update active_repo_path"
1757 );
1758 }
1759
1760 #[test]
1761 fn set_root_dir_post_activate_fires_against_new_root() {
1762 let dir = tempfile::tempdir().unwrap();
1763 let child = dir.path().join("child");
1764 std::fs::create_dir_all(&child).unwrap();
1765 std::fs::write(child.join("a.txt"), b"hi").unwrap();
1766 let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1767 let seen = seen_path.clone();
1768 let hook: PostActivateHook = Arc::new(move |p, _n| {
1769 *seen.lock().unwrap() = Some(p.to_path_buf());
1770 Ok(())
1771 });
1772 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1773 let _ = ws.set_root_dir(&child, None);
1774 assert_eq!(
1775 seen_path.lock().unwrap().clone().unwrap(),
1776 child.canonicalize().unwrap(),
1777 "post_activate hook saw the wrong root after set_root_dir"
1778 );
1779 }
1780
1781 #[test]
1782 fn activation_summary_appended_to_activate_message() {
1783 let dir = tempfile::tempdir().unwrap();
1784 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1785 let summary: ActivationSummaryHook =
1786 Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
1787 let ws = Workspace::open_local(dir.path().to_path_buf(), None)
1788 .unwrap()
1789 .with_activation_summary(summary);
1790 let out = ws.repo_management(None, false, true, false, None);
1791 assert!(
1792 out.contains("Graph ready: 3 Functions."),
1793 "activation message should include the summary; got: {out}"
1794 );
1795 }
1796
1797 #[test]
1798 fn activation_summary_absent_when_not_configured() {
1799 let dir = tempfile::tempdir().unwrap();
1800 std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1801 let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1802 let out = ws.repo_management(None, false, true, false, None);
1803 assert!(!out.contains("Graph ready"));
1804 assert!(
1805 out.contains(" at "),
1806 "expected the terse default message; got: {out}"
1807 );
1808 }
1809
1810 #[test]
1811 fn hook_fires_once_per_process_even_when_sha_matches() {
1812 use std::sync::atomic::{AtomicUsize, Ordering};
1813 let dir = tempfile::tempdir().unwrap();
1817 std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1818
1819 let calls = Arc::new(AtomicUsize::new(0));
1820 let make_hook = || -> PostActivateHook {
1821 let c = calls.clone();
1822 Arc::new(move |_p, _n| {
1823 c.fetch_add(1, Ordering::SeqCst);
1824 Ok(())
1825 })
1826 };
1827
1828 let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1830 let _ = ws.repo_management(None, false, true, false, None);
1832 assert_eq!(
1833 calls.load(Ordering::SeqCst),
1834 1,
1835 "first activate must hydrate"
1836 );
1837 let out = ws.repo_management(None, false, true, false, None);
1839 assert_eq!(
1840 calls.load(Ordering::SeqCst),
1841 1,
1842 "repeat activate in same process must skip the hook"
1843 );
1844 assert!(
1845 out.contains("build skipped"),
1846 "expected skip suffix, got: {out}"
1847 );
1848 drop(ws);
1849
1850 let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1855 assert!(
1856 ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1857 .is_some(),
1858 "sanity: last_built_sha should survive the restart"
1859 );
1860 let _ = ws2.repo_management(None, false, true, false, None);
1861 assert_eq!(
1862 calls.load(Ordering::SeqCst),
1863 2,
1864 "fresh process must re-fire the hook even when the SHA matches"
1865 );
1866 }
1867
1868 #[test]
1869 fn a_b_a_swap_rebuilds_intervening_root() {
1870 use std::sync::atomic::{AtomicUsize, Ordering};
1876 let root = tempfile::tempdir().unwrap();
1877 let a = root.path().join("projA");
1878 let b = root.path().join("projB");
1879 std::fs::create_dir_all(&a).unwrap();
1880 std::fs::create_dir_all(&b).unwrap();
1881 std::fs::write(a.join("a.txt"), b"alpha").unwrap();
1885 std::fs::write(b.join("b.txt"), b"beta").unwrap();
1886
1887 let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1890 let built_h = built.clone();
1891 let calls = Arc::new(AtomicUsize::new(0));
1892 let calls_h = calls.clone();
1893 let hook: PostActivateHook = Arc::new(move |p, _n| {
1894 *built_h.lock().unwrap() = Some(p.to_path_buf());
1895 calls_h.fetch_add(1, Ordering::SeqCst);
1896 Ok(())
1897 });
1898
1899 let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
1900 let _ = ws.set_root_dir(&a, None);
1903 assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
1904 assert_eq!(
1905 built.lock().unwrap().clone(),
1906 Some(a.canonicalize().unwrap())
1907 );
1908
1909 let _ = ws.set_root_dir(&b, None);
1910 assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
1911 assert_eq!(
1912 built.lock().unwrap().clone(),
1913 Some(b.canonicalize().unwrap())
1914 );
1915
1916 let out = ws.set_root_dir(&a, None);
1919 assert_eq!(
1920 calls.load(Ordering::SeqCst),
1921 3,
1922 "A→B→A must rebuild A; the intervening B overwrote the live slot"
1923 );
1924 assert!(
1925 !out.contains("build skipped"),
1926 "re-bind of a non-active root must not skip; got: {out}"
1927 );
1928 assert_eq!(
1929 built.lock().unwrap().clone(),
1930 Some(a.canonicalize().unwrap()),
1931 "after A→B→A the live slot must hold A, not B"
1932 );
1933
1934 let out = ws.set_root_dir(&a, None);
1937 assert_eq!(
1938 calls.load(Ordering::SeqCst),
1939 3,
1940 "re-binding the already-active root must skip the hook"
1941 );
1942 assert!(
1943 out.contains("build skipped"),
1944 "expected skip suffix, got: {out}"
1945 );
1946 }
1947
1948 fn git_repo_with_tags(tags: &[&str]) -> Option<(tempfile::TempDir, PathBuf)> {
1957 let dir = tempfile::tempdir().unwrap();
1958 let root = dir.path().to_path_buf();
1959 let git = |args: &[&str]| {
1960 Command::new("git")
1961 .arg("-C")
1962 .arg(&root)
1963 .args(args)
1964 .output()
1965 .unwrap()
1966 };
1967 if !git(&["init"]).status.success() {
1968 return None; }
1970 git(&["config", "user.email", "t@example.com"]);
1971 git(&["config", "user.name", "Test"]);
1972 git(&["config", "commit.gpgsign", "false"]);
1973 for (i, tag) in tags.iter().enumerate() {
1974 std::fs::write(root.join("f.txt"), format!("rev {i}")).unwrap();
1975 git(&["add", "-A"]);
1976 assert!(
1977 git(&["commit", "-m", &format!("c{i}")]).status.success(),
1978 "git commit failed"
1979 );
1980 assert!(git(&["tag", tag]).status.success(), "git tag {tag} failed");
1981 }
1982 Some((dir, root))
1983 }
1984
1985 #[test]
1988 fn classify_tag_extracts_prefix_version_prerelease() {
1989 let c = classify_tag("apache-arrow-25.0.0").unwrap();
1990 assert_eq!(c.prefix, "apache-arrow-");
1991 assert_eq!(c.version, vec![25, 0, 0]);
1992 assert!(!c.is_prerelease);
1993
1994 for t in [
1996 "apache-arrow-25.0.0.dev",
1997 "apache-arrow-25.0.0-rc1",
1998 "apache-arrow-25.0.0-RC0",
1999 "v1.2.3-beta2",
2000 "v1.2.3_alpha",
2001 "v2.0.0-preview",
2002 ] {
2003 assert!(
2004 classify_tag(t).unwrap().is_prerelease,
2005 "{t} should be prerelease"
2006 );
2007 }
2008
2009 assert_eq!(classify_tag("go/v18.0.0").unwrap().prefix, "go/v");
2011 assert_eq!(classify_tag("r-15.0.1").unwrap().prefix, "r-");
2012 assert_eq!(classify_tag("v1.2.3").unwrap().prefix, "v");
2013
2014 let c = classify_tag("arrow2-0.17.0").unwrap();
2016 assert_eq!(c.prefix, "arrow2-");
2017 assert_eq!(c.version, vec![0, 17, 0]);
2018 }
2019
2020 #[test]
2021 fn classify_tag_excludes_non_version_tags() {
2022 assert_eq!(classify_tag("r-universe-release"), None);
2023 assert_eq!(classify_tag("latest"), None);
2024 assert_eq!(classify_tag("nightly"), None);
2025 assert_eq!(classify_tag("v1.2.3-foobar"), None);
2027 }
2028
2029 #[test]
2030 fn select_family_tags_picks_dominant_release_family_skipping_prereleases() {
2031 let tags: Vec<String> = [
2035 "apache-arrow-22.0.0",
2036 "apache-arrow-23.0.0",
2037 "apache-arrow-24.0.0",
2038 "apache-arrow-25.0.0-rc0",
2039 "apache-arrow-25.0.0-rc1",
2040 "apache-arrow-25.0.0.dev",
2041 "go/v18.0.0",
2042 "r-15.0.1",
2043 "r-16.1.0",
2044 "r-universe-release",
2045 ]
2046 .iter()
2047 .map(|s| s.to_string())
2048 .collect();
2049 let got = select_family_tags(&tags, 2).unwrap();
2052 assert_eq!(got, vec!["apache-arrow-23.0.0", "apache-arrow-24.0.0"]);
2053 }
2054
2055 #[test]
2056 fn select_family_tags_fewer_stable_than_requested_uses_all_stable() {
2057 let tags: Vec<String> = ["v1.0.0", "v2.0.0", "v3.0.0-rc1"]
2058 .iter()
2059 .map(|s| s.to_string())
2060 .collect();
2061 let got = select_family_tags(&tags, 5).unwrap();
2063 assert_eq!(got, vec!["v1.0.0", "v2.0.0"]);
2064 }
2065
2066 #[test]
2067 fn select_family_tags_prerelease_only_family_falls_back_to_prereleases() {
2068 let tags: Vec<String> = ["v1.0.0-rc1", "v1.0.0-rc2", "v0.9.0-beta"]
2069 .iter()
2070 .map(|s| s.to_string())
2071 .collect();
2072 let got = select_family_tags(&tags, 2).unwrap();
2074 assert_eq!(got, vec!["v1.0.0-rc1", "v1.0.0-rc2"]);
2075 }
2076
2077 #[test]
2078 fn select_family_tags_no_version_like_tags_returns_none() {
2079 let tags: Vec<String> = ["latest", "nightly", "stable"]
2080 .iter()
2081 .map(|s| s.to_string())
2082 .collect();
2083 assert_eq!(select_family_tags(&tags, 3), None);
2084 }
2085
2086 #[test]
2089 fn resolve_revs_count_falls_back_to_raw_when_no_version_tags() {
2090 let Some((_d, root)) = git_repo_with_tags(&["latest", "nightly", "stable"]) else {
2093 return;
2094 };
2095 let ws = Workspace::open_local(root.clone(), None).unwrap();
2096 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2097 assert_eq!(resolved.len(), 3);
2099 assert_eq!(resolved.last().unwrap(), "HEAD");
2100 assert!(resolved[..2].iter().all(|r| r != "HEAD"));
2101 }
2102
2103 #[test]
2104 fn resolve_revs_count_skips_prereleases_of_dominant_family() {
2105 let Some((_d, root)) =
2106 git_repo_with_tags(&["v1.0.0", "v2.0.0", "v3.0.0-rc1", "v3.0.0.dev"])
2107 else {
2108 return;
2109 };
2110 let ws = Workspace::open_local(root.clone(), None).unwrap();
2111 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(2)).unwrap();
2112 assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2115 }
2116
2117 #[test]
2118 fn resolve_revs_count_picks_newest_n_oldest_first_head_last() {
2119 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2120 return;
2121 };
2122 let ws = Workspace::open_local(root.clone(), None).unwrap();
2123 let resolved = ws
2124 .resolve_revs(&root, &RevsRequest::Count(2))
2125 .expect("resolve should succeed");
2126 assert_eq!(resolved, vec!["v1.1.0", "v2.0.0", "HEAD"]);
2128 }
2129
2130 #[test]
2131 fn resolve_revs_count_fewer_tags_than_requested_uses_all() {
2132 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2133 return;
2134 };
2135 let ws = Workspace::open_local(root.clone(), None).unwrap();
2136 let resolved = ws.resolve_revs(&root, &RevsRequest::Count(10)).unwrap();
2137 assert_eq!(resolved, vec!["v1.0.0", "v2.0.0", "HEAD"]);
2138 }
2139
2140 #[test]
2141 fn resolve_revs_count_errors_when_no_tags() {
2142 let Some((_d, root)) = git_repo_with_tags(&[]) else {
2143 return;
2144 };
2145 let git = |args: &[&str]| {
2147 Command::new("git")
2148 .arg("-C")
2149 .arg(&root)
2150 .args(args)
2151 .output()
2152 .unwrap()
2153 };
2154 std::fs::write(root.join("f.txt"), b"x").unwrap();
2155 git(&["add", "-A"]);
2156 git(&["commit", "-m", "c0"]);
2157 let ws = Workspace::open_local(root.clone(), None).unwrap();
2158 let err = ws
2159 .resolve_revs(&root, &RevsRequest::Count(3))
2160 .expect_err("no tags → error");
2161 assert!(
2162 err.to_string().contains("no tags"),
2163 "expected a 'no tags' error, got: {err}"
2164 );
2165 }
2166
2167 #[test]
2170 fn dedup_labels_is_order_preserving_first_wins() {
2171 assert_eq!(
2172 dedup_labels(vec!["HEAD".into(), "HEAD".into()]),
2173 vec!["HEAD"]
2174 );
2175 assert_eq!(
2176 dedup_labels(vec![
2177 "v1".into(),
2178 "v2".into(),
2179 "v1".into(),
2180 "v3".into(),
2181 "v2".into(),
2182 ]),
2183 vec!["v1", "v2", "v3"]
2184 );
2185 assert_eq!(dedup_labels(vec![]), Vec::<String>::new());
2187 assert_eq!(dedup_labels(vec!["a".into(), "b".into()]), vec!["a", "b"]);
2188 }
2189
2190 #[test]
2191 fn resolve_revs_list_dedups_duplicate_revspecs() {
2192 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2193 return;
2194 };
2195 let ws = Workspace::open_local(root.clone(), None).unwrap();
2196 let got = ws
2198 .resolve_revs(
2199 &root,
2200 &RevsRequest::List(vec!["HEAD".into(), "HEAD".into()]),
2201 )
2202 .unwrap();
2203 assert_eq!(got, vec!["HEAD"]);
2204 let got = ws
2206 .resolve_revs(
2207 &root,
2208 &RevsRequest::List(vec!["v1.0.0".into(), "HEAD".into(), "v1.0.0".into()]),
2209 )
2210 .unwrap();
2211 assert_eq!(got, vec!["v1.0.0", "HEAD"]);
2212 }
2213
2214 #[test]
2215 fn resolve_revs_list_validates_and_rejects_unknown() {
2216 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0"]) else {
2217 return;
2218 };
2219 let ws = Workspace::open_local(root.clone(), None).unwrap();
2220 let ok = ws
2222 .resolve_revs(
2223 &root,
2224 &RevsRequest::List(vec!["v1.1.0".into(), "v1.0.0".into()]),
2225 )
2226 .unwrap();
2227 assert_eq!(ok, vec!["v1.1.0", "v1.0.0"]);
2228 let err = ws
2230 .resolve_revs(&root, &RevsRequest::List(vec!["v9.9.9".into()]))
2231 .expect_err("unknown rev → error");
2232 assert!(
2233 err.to_string().contains("v9.9.9") && err.to_string().contains("does not exist"),
2234 "expected an unknown-rev error, got: {err}"
2235 );
2236 }
2237
2238 #[test]
2239 fn revs_hook_receives_resolved_revs_and_plain_hook_untouched() {
2240 use std::sync::atomic::{AtomicUsize, Ordering};
2241 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v1.1.0", "v2.0.0"]) else {
2242 return;
2243 };
2244 let plain_calls = Arc::new(AtomicUsize::new(0));
2245 let seen_revs: Arc<std::sync::Mutex<Option<Vec<String>>>> = Arc::new(Default::default());
2246 let pc = plain_calls.clone();
2247 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2248 pc.fetch_add(1, Ordering::SeqCst);
2249 Ok(())
2250 });
2251 let sr = seen_revs.clone();
2252 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, revs| {
2253 *sr.lock().unwrap() = Some(revs.to_vec());
2254 Ok(())
2255 });
2256 let ws = Workspace::open_local(root.clone(), Some(plain))
2257 .unwrap()
2258 .with_post_activate_revs(revs_hook);
2259 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(2)));
2260 assert_eq!(
2262 seen_revs.lock().unwrap().clone().unwrap(),
2263 vec!["v1.1.0", "v2.0.0", "HEAD"]
2264 );
2265 assert_eq!(
2266 plain_calls.load(Ordering::SeqCst),
2267 0,
2268 "plain hook must not fire when the revs-hook handled the request"
2269 );
2270 assert!(
2272 out.contains("revs: v1.1.0, v2.0.0, HEAD"),
2273 "activation message should list the resolved revs; got: {out}"
2274 );
2275 }
2276
2277 #[test]
2278 fn plain_hook_used_and_no_revs_line_when_no_revs_requested() {
2279 use std::sync::atomic::{AtomicUsize, Ordering};
2280 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0"]) else {
2281 return;
2282 };
2283 let plain_calls = Arc::new(AtomicUsize::new(0));
2284 let revs_seen = Arc::new(AtomicUsize::new(0));
2285 let pc = plain_calls.clone();
2286 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2287 pc.fetch_add(1, Ordering::SeqCst);
2288 Ok(())
2289 });
2290 let rs = revs_seen.clone();
2291 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _revs| {
2292 rs.fetch_add(1, Ordering::SeqCst);
2293 Ok(())
2294 });
2295 let ws = Workspace::open_local(root.clone(), Some(plain))
2296 .unwrap()
2297 .with_post_activate_revs(revs_hook);
2298 let out = ws.repo_management(None, false, true, false, None);
2300 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2301 assert_eq!(
2302 revs_seen.load(Ordering::SeqCst),
2303 0,
2304 "revs-hook must not fire when no revs were requested"
2305 );
2306 assert!(
2307 !out.contains("revs:"),
2308 "no revs line expected on a plain activation; got: {out}"
2309 );
2310 }
2311
2312 #[test]
2313 fn revs_requested_without_revs_hook_falls_back_to_plain_no_revs_line() {
2314 use std::sync::atomic::{AtomicUsize, Ordering};
2315 let Some((_d, root)) = git_repo_with_tags(&["v1.0.0", "v2.0.0"]) else {
2316 return;
2317 };
2318 let plain_calls = Arc::new(AtomicUsize::new(0));
2319 let pc = plain_calls.clone();
2320 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2321 pc.fetch_add(1, Ordering::SeqCst);
2322 Ok(())
2323 });
2324 let ws = Workspace::open_local(root.clone(), Some(plain)).unwrap();
2327 let out = ws.repo_management(None, false, true, false, Some(&RevsRequest::Count(1)));
2328 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2329 assert!(
2330 !out.contains("revs:"),
2331 "must not report a rev-set when only the plain hook ran; got: {out}"
2332 );
2333 }
2334
2335 #[allow(clippy::type_complexity)]
2341 fn ws_with_both_hooks(
2342 tags: &[&str],
2343 ) -> Option<(
2344 Workspace,
2345 tempfile::TempDir,
2346 PathBuf,
2347 Arc<std::sync::atomic::AtomicUsize>,
2348 Arc<std::sync::atomic::AtomicUsize>,
2349 )> {
2350 use std::sync::atomic::{AtomicUsize, Ordering};
2351 let (d, root) = git_repo_with_tags(tags)?;
2352 let plain_calls = Arc::new(AtomicUsize::new(0));
2353 let revs_calls = Arc::new(AtomicUsize::new(0));
2354 let pc = plain_calls.clone();
2355 let plain: PostActivateHook = Arc::new(move |_p, _n| {
2356 pc.fetch_add(1, Ordering::SeqCst);
2357 Ok(())
2358 });
2359 let rc = revs_calls.clone();
2360 let revs_hook: PostActivateRevsHook = Arc::new(move |_p, _n, _r| {
2361 rc.fetch_add(1, Ordering::SeqCst);
2362 Ok(())
2363 });
2364 let ws = Workspace::open_local(root.clone(), Some(plain))
2365 .unwrap()
2366 .with_post_activate_revs(revs_hook);
2367 Some((ws, d, root, plain_calls, revs_calls))
2368 }
2369
2370 #[test]
2371 fn plain_activation_after_revs_build_rebuilds_plain() {
2372 use std::sync::atomic::Ordering;
2373 let Some((ws, _d, root, plain_calls, revs_calls)) =
2374 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2375 else {
2376 return;
2377 };
2378 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2380 assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2381 assert_eq!(plain_calls.load(Ordering::SeqCst), 0);
2382 let out = ws.set_root_dir(&root, None);
2386 assert_eq!(
2387 plain_calls.load(Ordering::SeqCst),
2388 1,
2389 "plain re-activation after a revs build must fire the plain hook"
2390 );
2391 assert!(
2392 !out.contains("build skipped"),
2393 "must not skip a plain re-activation after a revs build; got: {out}"
2394 );
2395 assert!(
2396 !out.contains("revs:"),
2397 "plain rebuild must not claim revs; got: {out}"
2398 );
2399 let out = ws.set_root_dir(&root, None);
2402 assert_eq!(
2403 plain_calls.load(Ordering::SeqCst),
2404 1,
2405 "second plain re-bind skips"
2406 );
2407 assert!(
2408 out.contains("build skipped"),
2409 "expected skip suffix; got: {out}"
2410 );
2411 }
2412
2413 #[test]
2414 fn update_after_revs_build_reapplies_stored_revs() {
2415 use std::sync::atomic::Ordering;
2416 let Some((ws, _d, root, plain_calls, revs_calls)) =
2417 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2418 else {
2419 return;
2420 };
2421 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2423 assert_eq!(revs_calls.load(Ordering::SeqCst), 1);
2424 let out = ws.repo_management(None, false, true, false, None);
2427 assert_eq!(
2428 revs_calls.load(Ordering::SeqCst),
2429 2,
2430 "bare update must re-apply the stored rev-set"
2431 );
2432 assert_eq!(
2433 plain_calls.load(Ordering::SeqCst),
2434 0,
2435 "bare update after a revs build must not fall to the plain hook"
2436 );
2437 assert!(
2438 out.contains("revs:"),
2439 "re-applied update should list the revs; got: {out}"
2440 );
2441 }
2442
2443 #[test]
2444 fn revs_activation_after_plain_build_always_rebuilds() {
2445 use std::sync::atomic::Ordering;
2446 let Some((ws, _d, root, plain_calls, revs_calls)) =
2447 ws_with_both_hooks(&["v1.0.0", "v2.0.0"])
2448 else {
2449 return;
2450 };
2451 let _ = ws.set_root_dir(&root, None);
2453 assert_eq!(plain_calls.load(Ordering::SeqCst), 1);
2454 assert_eq!(revs_calls.load(Ordering::SeqCst), 0);
2455 let _ = ws.set_root_dir(&root, Some(&RevsRequest::Count(2)));
2458 assert_eq!(
2459 revs_calls.load(Ordering::SeqCst),
2460 1,
2461 "a revs request must always rebuild, even at an unchanged HEAD"
2462 );
2463 }
2464
2465 #[test]
2466 fn last_built_revs_round_trips_and_clears_on_plain_build() {
2467 let dir = tempfile::tempdir().unwrap();
2468 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2469 ws.bump_access("acme/widgets", "cloned");
2470 assert_eq!(ws.last_built_revs("acme/widgets"), None);
2471 ws.record_built("acme/widgets", "sha1", Some(&RevsRequest::Count(3)));
2473 assert_eq!(
2474 ws.last_built_revs("acme/widgets"),
2475 Some(RevsRequest::Count(3))
2476 );
2477 let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2479 assert_eq!(
2480 ws2.last_built_revs("acme/widgets"),
2481 Some(RevsRequest::Count(3))
2482 );
2483 ws2.record_built("acme/widgets", "sha2", None);
2485 assert_eq!(ws2.last_built_revs("acme/widgets"), None);
2486 ws2.record_built(
2488 "acme/widgets",
2489 "sha3",
2490 Some(&RevsRequest::List(vec!["v1".into(), "v2".into()])),
2491 );
2492 assert_eq!(
2493 ws2.last_built_revs("acme/widgets"),
2494 Some(RevsRequest::List(vec!["v1".into(), "v2".into()]))
2495 );
2496 }
2497
2498 #[test]
2499 fn inventory_loads_legacy_entries_without_revs_field() {
2500 let dir = tempfile::tempdir().unwrap();
2504 let legacy = r#"{
2505 "old/repo": {
2506 "cloned_at": "2024-01-01T00:00:00",
2507 "last_accessed": "2024-01-01T00:00:00",
2508 "access_count": 5,
2509 "stale": false,
2510 "last_built_sha": "deadbeef"
2511 }
2512 }"#;
2513 std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
2514 let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
2515 assert_eq!(ws.last_built_sha("old/repo").as_deref(), Some("deadbeef"));
2516 assert_eq!(ws.last_built_revs("old/repo"), None);
2517 }
2518}