Skip to main content

mcp_methods/server/
workspace.rs

1//! Workspace mode — two variants.
2//!
3//! **Github mode** (`Workspace::open`, the default when
4//! `--workspace DIR` is set): the agent activates a GitHub repo via
5//! `repo_management('org/repo')`, the binary clones it into the
6//! workspace, and the active repo becomes the bound source root for
7//! `read_source` / `grep` / `list_source`. Idle repos auto-sweep after
8//! `--stale-after-days`. Layout:
9//!   workspace/
10//!     repos/<org>/<repo>/         — cloned source
11//!     inventory.json              — per-repo access tracking
12//!
13//! **Local mode** (`Workspace::open_local`, the manifest-driven
14//! `workspace: { kind: local, root: ... }` variant): the active source
15//! root is a fixed local directory, not a clone target. `repo_management`
16//! reports the active root and triggers rebuilds; an `set_root_dir`
17//! tool can swap the root at runtime. Closes the `code_review_mcp_server`
18//! use case from the kglite wishlist.
19//!
20//! Both modes fire the same [`PostActivateHook`] so downstream binaries
21//! (kglite-mcp-server) layer their build step on top with one
22//! registration point, and both honour the same `last_built_sha`
23//! gating to skip pointless rebuilds.
24
25#![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
38/// Repo name format: ``org/repo``. Letters, digits, dots, hyphens, underscores.
39fn 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
61/// Hook fired after a successful clone or update. Receives the absolute
62/// path to the cloned repo and the org/repo name. Errors are logged but
63/// don't abort the activation — the repo is still registered as active.
64pub type PostActivateHook = Arc<dyn Fn(&Path, &str) -> Result<()> + Send + Sync>;
65
66/// Per-repo inventory entry persisted in `inventory.json`.
67#[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    /// HEAD SHA at the time the post-activate hook last completed
76    /// successfully. Drives auto-rebuild gating: when an `update=True`
77    /// call ends with `action=="current"` AND the new HEAD matches this,
78    /// the post-activate hook can be skipped. `serde(default)` keeps
79    /// older inventory.json files (without this field) loading cleanly.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    last_built_sha: Option<String>,
82}
83
84// `WorkspaceKind` is re-used from the manifest module so config and
85// runtime share one enum — the values mean the same thing.
86pub use crate::server::manifest::WorkspaceKind;
87
88/// Workspace runtime state. Shared across MCP request clones via Arc.
89#[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    /// Open a github-flavoured workspace (clone + track flow).
110    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    /// Open a local-directory workspace.
139    ///
140    /// Binds `root` as the active source root immediately and fires the
141    /// post-activate hook (subject to last-built-sha gating). `inventory.json`
142    /// is kept under `<root>/.mcp-workspace/` so the local mode mirrors
143    /// the same gating / fingerprinting infra without polluting the
144    /// user's tree with a `repos/` directory.
145    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        // Store inventory under a hidden subdir so we don't litter the
156        // user's repo. The "workspace dir" for local mode IS the root.
157        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, // sweeping is github-only
172                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    /// Active repo's full org/repo name, or None if nothing is active.
202    pub fn active_repo_name(&self) -> Option<String> {
203        self.inner.state.read().unwrap().active_repo_name.clone()
204    }
205
206    /// Active repo's filesystem path, or None.
207    pub fn active_repo_path(&self) -> Option<PathBuf> {
208        self.inner.state.read().unwrap().active_repo_path.clone()
209    }
210
211    /// Default `org/repo` for the GitHub tools when the caller passes none.
212    ///
213    /// Github mode: the active repo — there the inventory key *is* the
214    /// `org/repo`. Local mode: the active root's `origin` remote parsed
215    /// to `org/repo`, or `None` when there's no GitHub remote. Crucially
216    /// it is *never* the `local/<dir>` inventory key (see
217    /// [`active_repo_name`](Self::active_repo_name)), which is a
218    /// filesystem-derived key, not a valid repo slug.
219    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    // ------------------------------------------------------------------
227    // Inventory management
228    // ------------------------------------------------------------------
229
230    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        // Local mode has nothing to sweep — the operator owns the root.
327        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    // ------------------------------------------------------------------
386    // Git operations
387    // ------------------------------------------------------------------
388
389    /// Clone (if missing) or fast-forward (if cloned). Returns the
390    /// action label, the repo path, and the new HEAD SHA after the op.
391    ///
392    /// Local-mode short-circuits: there's nothing to clone or fetch.
393    /// The "SHA" is a cheap content fingerprint (recursive walk of file
394    /// mtimes + sizes) so the auto-rebuild gate still works.
395    fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
396        if matches!(self.inner.kind, WorkspaceKind::Local) {
397            // Local mode tracks the *currently bound* root, not the
398            // immutable configured `workspace_dir`. `set_root_dir` writes
399            // the target to `active_repo_path` before calling `activate`;
400            // this read picks that up so the fingerprint and the
401            // post-activate hook fire against the new root, and so the
402            // subsequent `active_repo_path` write in `activate` doesn't
403            // clobber the just-set target back to `workspace_dir`. Falls
404            // back to `workspace_dir` only if state is unset, which
405            // shouldn't happen after `open_local` seeds it.
406            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", // first activation
419                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        // Fetch + check head delta
443        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    /// Activate a repo: clone if needed, fast-forward, fire post-activate hook.
463    ///
464    /// Auto-rebuild gating: if `force_rebuild` is false AND the repo
465    /// is already at the HEAD it was last built at (`action == "current"`
466    /// AND `prev_built_sha == new_head`), the post-activate hook is
467    /// skipped. This makes `repo_management(update=True)` cheap when
468    /// upstream hasn't moved. Set `force_rebuild=true` to bypass (e.g.
469    /// after upgrading the builder itself).
470    ///
471    /// On successful hook completion the new HEAD SHA is persisted to
472    /// `inventory.json[name].last_built_sha`. If the hook fails the SHA
473    /// is NOT recorded, so the next `update=True` re-attempts the build.
474    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            // No hook configured — record the SHA so future calls can
501            // see "no work to do" without consulting an empty store.
502            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    /// Read the SHA recorded after the last successful post-activate hook
533    /// for the named repo. `None` if the repo was never built (or the
534    /// hook last failed). Useful for downstream consumers gating
535    /// "is the active graph up to date with the repo HEAD?" checks.
536    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    /// Public entry for the `repo_management` MCP tool.
621    ///
622    /// - `name`: `org/repo` to activate (None = list / refresh mode).
623    /// - `delete`: remove the named repo + inventory entry. Github only.
624    /// - `update`: refresh the active repo (auto-rebuild gated).
625    /// - `force_rebuild`: with `update=true` (or initial activation),
626    ///   re-run the post-activate hook even when the HEAD SHA matches
627    ///   `last_built_sha`. Useful after the builder itself has been
628    ///   upgraded.
629    ///
630    /// Local mode behaviour: `name` and `delete` are rejected; pass
631    /// `update=true` (or no args after the initial activation) to
632    /// re-fingerprint the root and rebuild if anything changed.
633    pub fn repo_management(
634        &self,
635        name: Option<&str>,
636        delete: bool,
637        update: bool,
638        force_rebuild: bool,
639    ) -> String {
640        // Local mode: most github-only semantics are nonsensical here.
641        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            // `update`: re-fingerprint and rebuild if anything changed.
658            // `force_rebuild`: rebuild even when the fingerprint matches.
659            // Either flag (or neither — initial bind path) routes through
660            // `activate`; `activate` itself consults the gate using the
661            // force flag plus the SHA comparison.
662            let _ = update; // explicit: update is implicit in local mode
663            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    /// Swap the active root (local mode only). Re-fires the post-activate
713    /// hook against the new root. Errors if the workspace is github-flavoured.
714    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        // Note: the WorkspaceInner.workspace_dir field is the path the
735        // inventory is stored under. We keep the *original* one (from
736        // open_local) so the inventory survives across root swaps.
737        self.activate(&synthetic, false)
738            .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
739    }
740}
741
742/// Synthesise a stable "repo name" for a local workspace from its path.
743/// Used as the inventory key so the same gating + persistence code paths
744/// that github mode uses can apply to local mode unchanged.
745fn 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
753/// Parse the `org/repo` slug from a local checkout's `origin` remote.
754///
755/// Shells out to `git -C <root> remote get-url origin` and parses both
756/// canonical GitHub remote forms, stripping the trailing `.git`:
757///   - `git@github.com:kkollsga/kglite.git`     → `kkollsga/kglite`
758///   - `https://github.com/kkollsga/kglite.git` → `kkollsga/kglite`
759///
760/// Returns `None` for a non-git directory, a missing `origin` remote, or
761/// a non-GitHub remote — so the GitHub tools fall back to their existing
762/// empty-default path (ask the caller for `repo_name`).
763fn 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
777/// Pure-string half of [`parse_origin_repo`]: turn a GitHub remote URL
778/// into `org/repo`, or `None` if it isn't a recognisable GitHub remote.
779fn parse_github_remote(url: &str) -> Option<String> {
780    // Accept both SSH (`git@github.com:org/repo`) and HTTPS
781    // (`https://github.com/org/repo`) forms; everything after the host
782    // separator is the path.
783    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    // Must be exactly `org/repo` — both segments non-empty, one slash.
791    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
800/// Cheap recursive content fingerprint of a directory tree. Walks files
801/// (respecting common ignore patterns) and folds `(path, mtime, len)`
802/// into a 64-bit hash, then hex-formats it. Good enough to detect
803/// "did anything change?" for auto-rebuild gating — not cryptographic.
804fn 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    // Lightweight RFC3339-ish formatter. Drop sub-second precision; matches Python isoformat(timespec=seconds).
849    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
872/// Tiny self-contained ISO-8601 (seconds-precision) formatter so we
873/// don't pull in `chrono` for a handful of timestamps.
874mod chrono_lite {
875    pub fn format_secs(secs: u64) -> String {
876        // Civil-from-days algorithm (Howard Hinnant). Output: YYYY-MM-DDTHH:MM:SS.
877        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        // Accept "YYYY-MM-DDTHH:MM:SS" (no zone) — same shape as format_secs output
888        // and Python's datetime.isoformat(timespec="seconds").
889        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// silences unused-import-when-helper-only-via-json! macro check.
927#[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        // Seed an inventory entry directly (clone_or_update needs git).
992        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        // Survives an Workspace::open re-read (proves persistence).
1000        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        // Hand-craft an old-style inventory.json without `last_built_sha`.
1012        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        // Re-open and confirm graceful read.
1022        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        // Build a workspace pointing at a tempdir with a fake repo dir,
1038        // then simulate consecutive activates. We can't drive clone_or_update
1039        // without git, so test the gating directly by tracking the SHA
1040        // record-then-re-record case via Workspace::record_built_sha +
1041        // last_built_sha — the same predicate `activate` uses.
1042        let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1043        // Seed inventory entry + initial sha record.
1044        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        // Repeated record with the same value is idempotent (gating
1051        // logic uses last_built_sha as the source of truth).
1052        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        // No hook calls have been driven directly — this test exercises
1058        // the persistence path that the gate consults.
1059        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        // Drop a file so the fingerprint has something to hash.
1086        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        // First update: nothing built yet → hook fires.
1095        let _ = ws.repo_management(None, false, true, false);
1096        assert_eq!(calls.load(Ordering::SeqCst), 1);
1097        // Second update without changes → SHA matches → hook skipped.
1098        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        // No .git suffix, trailing slash.
1118        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        // Non-github / malformed → None.
1127        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        // Stand up a real git repo with a faked origin so default_github_repo
1140        // exercises the actual `git remote get-url` path.
1141        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            // git unavailable in this environment — skip rather than fail.
1151            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        // The inventory key remains the synthetic local name.
1166        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        // No git remote → None, and crucially NOT Some("local/<dir>").
1174        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}