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/// Optional hook that returns a short agent-facing summary appended to
67/// the activation result message — the "graph ready" mini-map / opening
68/// steer (e.g. `"Graph ready: 9,999 Functions · 656 Classes · 31k CALLS.
69/// Open with graph_overview() → cypher_query; grep = literal text only."`).
70///
71/// Kept separate from [`PostActivateHook`] so adding it is a non-breaking
72/// addition — existing consumers that register only the build hook are
73/// unaffected. Receives the repo path + name; returns `Some(text)` to
74/// append (blank-line separated), or `None` for the terse default
75/// message. Called after a successful activation (skipped when the build
76/// hook failed).
77pub type ActivationSummaryHook = Arc<dyn Fn(&Path, &str) -> Option<String> + Send + Sync>;
78
79/// Per-repo inventory entry persisted in `inventory.json`.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81struct InventoryEntry {
82    cloned_at: String,
83    last_accessed: String,
84    #[serde(default)]
85    access_count: u64,
86    #[serde(default)]
87    stale: bool,
88    /// HEAD SHA at the time the post-activate hook last completed
89    /// successfully. Drives auto-rebuild gating: when an `update=True`
90    /// call ends with `action=="current"` AND the new HEAD matches this,
91    /// the post-activate hook can be skipped. `serde(default)` keeps
92    /// older inventory.json files (without this field) loading cleanly.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    last_built_sha: Option<String>,
95}
96
97// `WorkspaceKind` is re-used from the manifest module so config and
98// runtime share one enum — the values mean the same thing.
99pub use crate::server::manifest::WorkspaceKind;
100
101/// Workspace runtime state. Shared across MCP request clones via Arc.
102#[derive(Clone)]
103pub struct Workspace {
104    inner: Arc<WorkspaceInner>,
105}
106
107struct WorkspaceInner {
108    kind: WorkspaceKind,
109    workspace_dir: PathBuf,
110    stale_after_days: u32,
111    state: RwLock<WorkspaceState>,
112    post_activate: Option<PostActivateHook>,
113    /// Optional summary hook (see [`ActivationSummaryHook`]). Set via
114    /// [`Workspace::with_activation_summary`], `None` by default.
115    activation_summary: Option<ActivationSummaryHook>,
116}
117
118#[derive(Debug, Default)]
119struct WorkspaceState {
120    active_repo_name: Option<String>,
121    active_repo_path: Option<PathBuf>,
122    /// The name whose post-activate hook **most recently ran to
123    /// completion in this process** — i.e. the root whose product is
124    /// currently live. Deliberately NOT persisted: `last_built_sha`
125    /// records that the git repo is at the built SHA (a cross-process
126    /// fact), but the hook's *product* — the consumer's in-memory graph
127    /// — lives only for the process lifetime. On a fresh process this is
128    /// `None`, so the first activate re-fires the hook to rehydrate even
129    /// when the persisted SHA already matches HEAD.
130    ///
131    /// Crucially this is a **single** name, not a set of every name ever
132    /// hydrated. Many consumers keep a *single* active-graph slot that
133    /// each activate overwrites, so "hook fired for X at some point" does
134    /// NOT imply "X's product is still live". Only re-binding the
135    /// currently-active root is safe to skip; an A→B→A swap must rebuild
136    /// A because B overwrote the slot. Tracking one name makes the skip
137    /// gate correct for single-slot and per-name consumers alike.
138    active_built_name: Option<String>,
139}
140
141impl Workspace {
142    /// Open a github-flavoured workspace (clone + track flow).
143    pub fn open(
144        workspace_dir: PathBuf,
145        stale_after_days: u32,
146        post_activate: Option<PostActivateHook>,
147    ) -> Result<Self> {
148        if !workspace_dir.is_dir() {
149            fs::create_dir_all(&workspace_dir).with_context(|| {
150                format!("failed to create workspace dir {}", workspace_dir.display())
151            })?;
152        }
153        let repos_dir = workspace_dir.join("repos");
154        if !repos_dir.is_dir() {
155            fs::create_dir_all(&repos_dir)
156                .with_context(|| format!("failed to create repos dir {}", repos_dir.display()))?;
157        }
158        let ws = Self {
159            inner: Arc::new(WorkspaceInner {
160                kind: WorkspaceKind::Github,
161                workspace_dir,
162                stale_after_days,
163                state: RwLock::new(WorkspaceState::default()),
164                post_activate,
165                activation_summary: None,
166            }),
167        };
168        ws.reconcile_inventory()?;
169        Ok(ws)
170    }
171
172    /// Open a local-directory workspace.
173    ///
174    /// Binds `root` as the active source root immediately and fires the
175    /// post-activate hook (subject to last-built-sha gating). `inventory.json`
176    /// is kept under `<root>/.mcp-workspace/` so the local mode mirrors
177    /// the same gating / fingerprinting infra without polluting the
178    /// user's tree with a `repos/` directory.
179    pub fn open_local(root: PathBuf, post_activate: Option<PostActivateHook>) -> Result<Self> {
180        if !root.is_dir() {
181            anyhow::bail!(
182                "local workspace root does not exist or is not a directory: {}",
183                root.display()
184            );
185        }
186        let canon_root = root
187            .canonicalize()
188            .with_context(|| format!("failed to canonicalize local root {}", root.display()))?;
189        // Store inventory under a hidden subdir so we don't litter the
190        // user's repo. The "workspace dir" for local mode IS the root.
191        let inv_dir = canon_root.join(".mcp-workspace");
192        if !inv_dir.is_dir() {
193            fs::create_dir_all(&inv_dir).with_context(|| {
194                format!("failed to create local-workspace dir {}", inv_dir.display())
195            })?;
196        }
197        let mut state = WorkspaceState::default();
198        let synthetic_name = synthesize_local_name(&canon_root);
199        state.active_repo_name = Some(synthetic_name);
200        state.active_repo_path = Some(canon_root.clone());
201        Ok(Self {
202            inner: Arc::new(WorkspaceInner {
203                kind: WorkspaceKind::Local,
204                workspace_dir: canon_root,
205                stale_after_days: u32::MAX, // sweeping is github-only
206                state: RwLock::new(state),
207                post_activate,
208                activation_summary: None,
209            }),
210        })
211    }
212
213    /// Attach an [`ActivationSummaryHook`]. Call immediately after
214    /// `open`/`open_local` (before the workspace is cloned into
215    /// `ServerOptions`): it mutates the still-unique inner `Arc`. Calling
216    /// it after the workspace has been cloned is a no-op with a warning —
217    /// the summary simply won't be attached.
218    pub fn with_activation_summary(mut self, hook: ActivationSummaryHook) -> Self {
219        match Arc::get_mut(&mut self.inner) {
220            Some(inner) => inner.activation_summary = Some(hook),
221            None => tracing::warn!(
222                "with_activation_summary called after the workspace was cloned; summary not attached"
223            ),
224        }
225        self
226    }
227
228    pub fn kind(&self) -> WorkspaceKind {
229        self.inner.kind
230    }
231
232    pub fn workspace_dir(&self) -> &Path {
233        &self.inner.workspace_dir
234    }
235
236    pub fn repos_dir(&self) -> PathBuf {
237        self.inner.workspace_dir.join("repos")
238    }
239
240    fn inventory_path(&self) -> PathBuf {
241        match self.inner.kind {
242            WorkspaceKind::Github => self.inner.workspace_dir.join("inventory.json"),
243            WorkspaceKind::Local => self
244                .inner
245                .workspace_dir
246                .join(".mcp-workspace")
247                .join("inventory.json"),
248        }
249    }
250
251    /// Active repo's full org/repo name, or None if nothing is active.
252    pub fn active_repo_name(&self) -> Option<String> {
253        self.inner.state.read().unwrap().active_repo_name.clone()
254    }
255
256    /// Active repo's filesystem path, or None.
257    pub fn active_repo_path(&self) -> Option<PathBuf> {
258        self.inner.state.read().unwrap().active_repo_path.clone()
259    }
260
261    /// Default `org/repo` for the GitHub tools when the caller passes none.
262    ///
263    /// Github mode: the active repo — there the inventory key *is* the
264    /// `org/repo`. Local mode: the active root's `origin` remote parsed
265    /// to `org/repo`, or `None` when there's no GitHub remote. Crucially
266    /// it is *never* the `local/<dir>` inventory key (see
267    /// [`active_repo_name`](Self::active_repo_name)), which is a
268    /// filesystem-derived key, not a valid repo slug.
269    pub fn default_github_repo(&self) -> Option<String> {
270        match self.inner.kind {
271            WorkspaceKind::Github => self.active_repo_name(),
272            WorkspaceKind::Local => self.active_repo_path().and_then(|p| parse_origin_repo(&p)),
273        }
274    }
275
276    // ------------------------------------------------------------------
277    // Inventory management
278    // ------------------------------------------------------------------
279
280    fn load_inventory(&self) -> BTreeMap<String, InventoryEntry> {
281        let path = self.inventory_path();
282        let Ok(text) = fs::read_to_string(&path) else {
283            return BTreeMap::new();
284        };
285        serde_json::from_str(&text).unwrap_or_default()
286    }
287
288    fn save_inventory(&self, inv: &BTreeMap<String, InventoryEntry>) -> Result<()> {
289        let path = self.inventory_path();
290        let body = serde_json::to_string_pretty(inv).context("failed to serialise inventory")?;
291        fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?;
292        Ok(())
293    }
294
295    fn reconcile_inventory(&self) -> Result<()> {
296        let mut inv = self.load_inventory();
297        let mut on_disk: Vec<String> = Vec::new();
298        if self.repos_dir().is_dir() {
299            for org_entry in fs::read_dir(self.repos_dir())? {
300                let Ok(org_entry) = org_entry else { continue };
301                if !org_entry.path().is_dir() {
302                    continue;
303                }
304                let org = org_entry.file_name().to_string_lossy().into_owned();
305                if org.starts_with('.') {
306                    continue;
307                }
308                for repo_entry in fs::read_dir(org_entry.path())? {
309                    let Ok(repo_entry) = repo_entry else { continue };
310                    if !repo_entry.path().is_dir() {
311                        continue;
312                    }
313                    let repo = repo_entry.file_name().to_string_lossy().into_owned();
314                    if repo.starts_with('.') {
315                        continue;
316                    }
317                    let rname = format!("{org}/{repo}");
318                    on_disk.push(rname.clone());
319                    inv.entry(rname).or_insert_with(|| {
320                        let mtime = repo_entry
321                            .metadata()
322                            .ok()
323                            .and_then(|m| m.modified().ok())
324                            .map(format_iso)
325                            .unwrap_or_else(now_iso);
326                        InventoryEntry {
327                            cloned_at: mtime.clone(),
328                            last_accessed: mtime,
329                            access_count: 0,
330                            stale: false,
331                            last_built_sha: None,
332                        }
333                    });
334                }
335            }
336        }
337        for (rname, entry) in inv.iter_mut() {
338            if !on_disk.contains(rname) && !entry.stale {
339                entry.stale = true;
340            }
341        }
342        self.save_inventory(&inv)?;
343        Ok(())
344    }
345
346    fn bump_access(&self, name: &str, action: &str) {
347        let mut inv = self.load_inventory();
348        let now = now_iso();
349        let entry = inv
350            .entry(name.to_string())
351            .or_insert_with(|| InventoryEntry {
352                cloned_at: now.clone(),
353                last_accessed: now.clone(),
354                access_count: 0,
355                stale: false,
356                last_built_sha: None,
357            });
358        entry.last_accessed = now.clone();
359        entry.access_count += 1;
360        entry.stale = false;
361        if action == "cloned" || entry.cloned_at.is_empty() {
362            entry.cloned_at = now;
363        }
364        let _ = self.save_inventory(&inv);
365    }
366
367    fn mark_stale(&self, name: &str) {
368        let mut inv = self.load_inventory();
369        if let Some(entry) = inv.get_mut(name) {
370            entry.stale = true;
371            let _ = self.save_inventory(&inv);
372        }
373    }
374
375    fn sweep_stale(&self) -> Vec<String> {
376        // Local mode has nothing to sweep — the operator owns the root.
377        if matches!(self.inner.kind, WorkspaceKind::Local) {
378            return Vec::new();
379        }
380        let mut inv = self.load_inventory();
381        let cutoff = SystemTime::now()
382            - std::time::Duration::from_secs(self.inner.stale_after_days as u64 * 86_400);
383        let active = self.active_repo_name();
384        let mut swept: Vec<String> = Vec::new();
385        for (rname, entry) in inv.iter_mut() {
386            if entry.stale {
387                continue;
388            }
389            if Some(rname.as_str()) == active.as_deref() {
390                continue;
391            }
392            let last = parse_iso(&entry.last_accessed).unwrap_or(SystemTime::UNIX_EPOCH);
393            if last >= cutoff {
394                continue;
395            }
396            let parts: Vec<&str> = rname.splitn(2, '/').collect();
397            if parts.len() != 2 {
398                continue;
399            }
400            let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
401            if repo_path.exists() {
402                let _ = fs::remove_dir_all(&repo_path);
403            }
404            entry.stale = true;
405            swept.push(rname.clone());
406        }
407        if !swept.is_empty() {
408            let _ = self.save_inventory(&inv);
409            self.prune_empty_org_dirs();
410        }
411        swept
412    }
413
414    fn prune_empty_org_dirs(&self) {
415        let Ok(entries) = fs::read_dir(self.repos_dir()) else {
416            return;
417        };
418        for entry in entries.flatten() {
419            let path = entry.path();
420            if !path.is_dir() {
421                continue;
422            }
423            if let Ok(children) = fs::read_dir(&path) {
424                let real: Vec<_> = children
425                    .flatten()
426                    .filter(|c| !c.file_name().to_string_lossy().starts_with('.'))
427                    .collect();
428                if real.is_empty() {
429                    let _ = fs::remove_dir_all(&path);
430                }
431            }
432        }
433    }
434
435    // ------------------------------------------------------------------
436    // Git operations
437    // ------------------------------------------------------------------
438
439    /// Clone (if missing) or fast-forward (if cloned). Returns the
440    /// action label, the repo path, and the new HEAD SHA after the op.
441    ///
442    /// Local-mode short-circuits: there's nothing to clone or fetch.
443    /// The "SHA" is a cheap content fingerprint (recursive walk of file
444    /// mtimes + sizes) so the auto-rebuild gate still works.
445    fn clone_or_update(&self, name: &str) -> Result<(String, PathBuf, String)> {
446        if matches!(self.inner.kind, WorkspaceKind::Local) {
447            // Local mode tracks the *currently bound* root, not the
448            // immutable configured `workspace_dir`. `set_root_dir` writes
449            // the target to `active_repo_path` before calling `activate`;
450            // this read picks that up so the fingerprint and the
451            // post-activate hook fire against the new root, and so the
452            // subsequent `active_repo_path` write in `activate` doesn't
453            // clobber the just-set target back to `workspace_dir`. Falls
454            // back to `workspace_dir` only if state is unset, which
455            // shouldn't happen after `open_local` seeds it.
456            let root = self
457                .inner
458                .state
459                .read()
460                .unwrap()
461                .active_repo_path
462                .clone()
463                .unwrap_or_else(|| self.inner.workspace_dir.clone());
464            let prev_sha = self.last_built_sha(name);
465            let fingerprint = fingerprint_dir(&root);
466            let action = match prev_sha {
467                Some(p) if p == fingerprint => "current",
468                None => "cloned", // first activation
469                Some(_) => "updated",
470            };
471            return Ok((action.to_string(), root, fingerprint));
472        }
473        let parts: Vec<&str> = name.splitn(2, '/').collect();
474        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
475        if !repo_path.exists() {
476            fs::create_dir_all(repo_path.parent().unwrap()).ok();
477            let url = format!("https://github.com/{name}.git");
478            let out = Command::new("git")
479                .args(["clone", "--depth", "1", &url, repo_path.to_str().unwrap()])
480                .output()
481                .context("failed to spawn `git clone`")?;
482            if !out.status.success() {
483                anyhow::bail!(
484                    "git clone failed: {}",
485                    String::from_utf8_lossy(&out.stderr).trim()
486                );
487            }
488            let sha = git_rev_parse(&repo_path, "HEAD")?;
489            return Ok(("cloned".to_string(), repo_path, sha));
490        }
491
492        // Fetch + check head delta
493        Command::new("git")
494            .args(["fetch", "--depth", "1", "origin"])
495            .current_dir(&repo_path)
496            .output()
497            .context("git fetch failed")?;
498        let local = git_rev_parse(&repo_path, "HEAD")?;
499        let remote = git_rev_parse(&repo_path, "FETCH_HEAD")?;
500        if local != remote {
501            Command::new("git")
502                .args(["reset", "--hard", "FETCH_HEAD"])
503                .current_dir(&repo_path)
504                .output()
505                .context("git reset failed")?;
506            let sha = git_rev_parse(&repo_path, "HEAD")?;
507            return Ok(("updated".to_string(), repo_path, sha));
508        }
509        Ok(("current".to_string(), repo_path, local))
510    }
511
512    /// Activate a repo: clone if needed, fast-forward, fire post-activate hook.
513    ///
514    /// Auto-rebuild gating: if `force_rebuild` is false AND the repo
515    /// is already at the HEAD it was last built at (`action == "current"`
516    /// AND `prev_built_sha == new_head`), the post-activate hook is
517    /// skipped. This makes `repo_management(update=True)` cheap when
518    /// upstream hasn't moved. Set `force_rebuild=true` to bypass (e.g.
519    /// after upgrading the builder itself).
520    ///
521    /// On successful hook completion the new HEAD SHA is persisted to
522    /// `inventory.json[name].last_built_sha`. If the hook fails the SHA
523    /// is NOT recorded, so the next `update=True` re-attempts the build.
524    fn activate(&self, name: &str, force_rebuild: bool) -> Result<String> {
525        let prev_built_sha = self.last_built_sha(name);
526        let (action, repo_path, head_sha) = self.clone_or_update(name)?;
527        self.bump_access(name, &action);
528        let is_active_built = {
529            let mut state = self.inner.state.write().unwrap();
530            state.active_repo_name = Some(name.to_string());
531            state.active_repo_path = Some(repo_path.clone());
532            state.active_built_name.as_deref() == Some(name)
533        };
534
535        // The skip gate must be satisfied on BOTH axes: the git repo is
536        // at its last-built SHA (persisted, cross-process) AND `name` is
537        // the *currently active* built root in this process (in-memory).
538        // Without the second axis a fresh process would inherit
539        // `last_built_sha` from disk, skip the hook, and leave the
540        // consumer's in-memory state (e.g. the code graph) empty —
541        // activate would report success with nothing loaded. The axis
542        // checks the *active* built name, not any name ever built, so an
543        // A→B→A swap correctly rebuilds A: after activate(B) the live
544        // slot holds B, so re-binding A must not skip (see the
545        // `active_built_name` field doc).
546        let already_built = !force_rebuild
547            && action == "current"
548            && prev_built_sha.as_deref() == Some(head_sha.as_str())
549            && is_active_built;
550        let mut hook_skipped = false;
551        let hook_ok = if already_built {
552            hook_skipped = true;
553            true
554        } else if let Some(hook) = &self.inner.post_activate {
555            match hook(&repo_path, name) {
556                Ok(()) => true,
557                Err(e) => {
558                    tracing::warn!("post-activate hook for {name} failed: {e}");
559                    false
560                }
561            }
562        } else {
563            // No hook configured — record the SHA so future calls can
564            // see "no work to do" without consulting an empty store.
565            true
566        };
567        if hook_ok {
568            self.record_built_sha(name, &head_sha);
569        }
570        // Mark this name the currently-active built root only when the
571        // hook actually ran this process (not on the cheap-skip path,
572        // where it is already the active built name). A no-op "no hook
573        // configured" activation also counts: there is no per-process
574        // product to lose, so a future same-root skip is safe. This
575        // *overwrites* any prior name — modelling that each activate
576        // replaces the live product — so the next swap back rebuilds.
577        if hook_ok && !hook_skipped {
578            self.inner.state.write().unwrap().active_built_name = Some(name.to_string());
579        }
580        let verb = match action.as_str() {
581            "cloned" => "Cloned",
582            "updated" => "Updated",
583            "current" => "Activated (already up to date)",
584            other => other,
585        };
586        let suffix = if hook_skipped {
587            " [build skipped: HEAD matches last-built SHA]"
588        } else {
589            ""
590        };
591        let base = format!("{verb} '{name}' at {}.{suffix}", repo_path.display());
592        // Append the consumer's opening-steer mini-map, if configured.
593        // Fired on any successful activation (fresh build or cheap-skip —
594        // in both cases the in-memory product is live this process); the
595        // hook recomputes the summary from that live state. Skipped only
596        // when the build hook itself failed.
597        let summary = if hook_ok {
598            self.inner
599                .activation_summary
600                .as_ref()
601                .and_then(|h| h(&repo_path, name))
602        } else {
603            None
604        };
605        Ok(match summary {
606            Some(s) if !s.is_empty() => format!("{base}\n\n{s}"),
607            _ => base,
608        })
609    }
610
611    fn record_built_sha(&self, name: &str, sha: &str) {
612        let mut inv = self.load_inventory();
613        if let Some(entry) = inv.get_mut(name) {
614            entry.last_built_sha = Some(sha.to_string());
615            let _ = self.save_inventory(&inv);
616        }
617    }
618
619    /// Read the SHA recorded after the last successful post-activate hook
620    /// for the named repo. `None` if the repo was never built (or the
621    /// hook last failed). Useful for downstream consumers gating
622    /// "is the active graph up to date with the repo HEAD?" checks.
623    pub fn last_built_sha(&self, name: &str) -> Option<String> {
624        self.load_inventory()
625            .get(name)
626            .and_then(|e| e.last_built_sha.clone())
627    }
628
629    fn delete(&self, name: &str) -> Result<String> {
630        let parts: Vec<&str> = name.splitn(2, '/').collect();
631        if parts.len() != 2 {
632            anyhow::bail!("Invalid repo name");
633        }
634        let repo_path = self.repos_dir().join(parts[0]).join(parts[1]);
635        let mut deleted = Vec::new();
636        if repo_path.exists() {
637            fs::remove_dir_all(&repo_path).context("failed to remove repo dir")?;
638            deleted.push("repo");
639        }
640        self.mark_stale(name);
641        self.prune_empty_org_dirs();
642        if deleted.is_empty() {
643            return Ok(format!("Nothing to delete — '{name}' not found."));
644        }
645        let mut state = self.inner.state.write().unwrap();
646        if state.active_repo_name.as_deref() == Some(name) {
647            state.active_repo_name = None;
648            state.active_repo_path = None;
649            return Ok(format!(
650                "Deleted {}. Active repo cleared.",
651                deleted.join(", ")
652            ));
653        }
654        Ok(format!("Deleted {}.", deleted.join(", ")))
655    }
656
657    fn list(&self) -> String {
658        let inv = self.load_inventory();
659        if inv.is_empty() {
660            return "No repos cloned yet. Call repo_management('org/repo') to clone one."
661                .to_string();
662        }
663        let active = self.active_repo_name();
664        let mut live: Vec<String> = Vec::new();
665        let mut stale_lines: Vec<String> = Vec::new();
666        for (rname, entry) in &inv {
667            let marker = if Some(rname.as_str()) == active.as_deref() {
668                " [active]"
669            } else {
670                ""
671            };
672            let access = format!(
673                "{} access{}, last {}",
674                entry.access_count,
675                if entry.access_count == 1 { "" } else { "es" },
676                relative_time(&entry.last_accessed)
677            );
678            if entry.stale {
679                stale_lines.push(format!(
680                    "  {rname}  [STALE — re-fetch with repo_management('{rname}')]  ({access})"
681                ));
682            } else {
683                live.push(format!("  {rname}{marker}  ({access})"));
684            }
685        }
686        let mut out = String::new();
687        if !live.is_empty() {
688            out.push_str(&format!(
689                "{} live repo(s):\n{}",
690                live.len(),
691                live.join("\n")
692            ));
693        }
694        if !stale_lines.is_empty() {
695            if !out.is_empty() {
696                out.push_str("\n\n");
697            }
698            out.push_str(&format!(
699                "{} stale repo(s):\n{}",
700                stale_lines.len(),
701                stale_lines.join("\n")
702            ));
703        }
704        out
705    }
706
707    /// Public entry for the `repo_management` MCP tool.
708    ///
709    /// - `name`: `org/repo` to activate (None = list / refresh mode).
710    /// - `delete`: remove the named repo + inventory entry. Github only.
711    /// - `update`: refresh the active repo (auto-rebuild gated).
712    /// - `force_rebuild`: with `update=true` (or initial activation),
713    ///   re-run the post-activate hook even when the HEAD SHA matches
714    ///   `last_built_sha`. Useful after the builder itself has been
715    ///   upgraded.
716    ///
717    /// Local mode behaviour: `name` and `delete` are rejected; pass
718    /// `update=true` (or no args after the initial activation) to
719    /// re-fingerprint the root and rebuild if anything changed.
720    pub fn repo_management(
721        &self,
722        name: Option<&str>,
723        delete: bool,
724        update: bool,
725        force_rebuild: bool,
726    ) -> String {
727        // Local mode: most github-only semantics are nonsensical here.
728        if matches!(self.inner.kind, WorkspaceKind::Local) {
729            if name.is_some() {
730                return "Local-workspace mode does not accept a repo name. Use `set_root_dir(path)` \
731                        to switch the active root, or pass `update=true` / `force_rebuild=true` \
732                        to rebuild against the current root."
733                    .to_string();
734            }
735            if delete {
736                return "Local-workspace mode does not support `delete`. The root is owned by the \
737                        operator; remove it manually."
738                    .to_string();
739            }
740            let active = match self.active_repo_name() {
741                Some(n) => n,
742                None => return "No active local root.".to_string(),
743            };
744            // `update`: re-fingerprint and rebuild if anything changed.
745            // `force_rebuild`: rebuild even when the fingerprint matches.
746            // Either flag (or neither — initial bind path) routes through
747            // `activate`; `activate` itself consults the gate using the
748            // force flag plus the SHA comparison.
749            let _ = update; // explicit: update is implicit in local mode
750            return self
751                .activate(&active, force_rebuild)
752                .unwrap_or_else(|e| format!("rebuild failed: {e}"));
753        }
754
755        let swept = self.sweep_stale();
756        let prefix = if swept.is_empty() {
757            String::new()
758        } else {
759            format!(
760                "[Swept {} idle repo(s) (>{}d): {}]\n\n",
761                swept.len(),
762                self.inner.stale_after_days,
763                swept.join(", ")
764            )
765        };
766
767        if name.is_none() && !update {
768            return prefix + &self.list();
769        }
770
771        if update {
772            let Some(active) = self.active_repo_name() else {
773                return prefix + "No active repository. Call repo_management('org/repo') first.";
774            };
775            return prefix
776                + &self
777                    .activate(&active, force_rebuild)
778                    .unwrap_or_else(|e| format!("update failed: {e}"));
779        }
780
781        let Some(name) = name else {
782            return prefix + "Provide a repo name (e.g. repo_management('org/repo')).";
783        };
784        if let Err(e) = validate_repo_name(name) {
785            return prefix + &e.to_string();
786        }
787        if delete {
788            return prefix
789                + &self
790                    .delete(name)
791                    .unwrap_or_else(|e| format!("delete failed: {e}"));
792        }
793        prefix
794            + &self
795                .activate(name, force_rebuild)
796                .unwrap_or_else(|e| format!("activate failed: {e}"))
797    }
798
799    /// Swap the active root (local mode only). Re-fires the post-activate
800    /// hook against the new root. Errors if the workspace is github-flavoured.
801    pub fn set_root_dir(&self, new_root: &Path) -> String {
802        if !matches!(self.inner.kind, WorkspaceKind::Local) {
803            return "set_root_dir is only valid in local-workspace mode.".to_string();
804        }
805        if !new_root.is_dir() {
806            return format!(
807                "Path does not exist or is not a directory: {}",
808                new_root.display()
809            );
810        }
811        let canon = match new_root.canonicalize() {
812            Ok(p) => p,
813            Err(e) => return format!("canonicalize failed: {e}"),
814        };
815        let synthetic = synthesize_local_name(&canon);
816        {
817            let mut state = self.inner.state.write().unwrap();
818            state.active_repo_name = Some(synthetic.clone());
819            state.active_repo_path = Some(canon.clone());
820        }
821        // Note: the WorkspaceInner.workspace_dir field is the path the
822        // inventory is stored under. We keep the *original* one (from
823        // open_local) so the inventory survives across root swaps.
824        self.activate(&synthetic, false)
825            .unwrap_or_else(|e| format!("set_root_dir failed: {e}"))
826    }
827}
828
829/// Synthesise a stable "repo name" for a local workspace from its path.
830/// Used as the inventory key so the same gating + persistence code paths
831/// that github mode uses can apply to local mode unchanged.
832fn synthesize_local_name(root: &Path) -> String {
833    let name = root
834        .file_name()
835        .map(|s| s.to_string_lossy().into_owned())
836        .unwrap_or_else(|| "local".to_string());
837    format!("local/{name}")
838}
839
840/// Parse the `org/repo` slug from a local checkout's `origin` remote.
841///
842/// Shells out to `git -C <root> remote get-url origin` and parses both
843/// canonical GitHub remote forms, stripping the trailing `.git`:
844///   - `git@github.com:kkollsga/kglite.git`     → `kkollsga/kglite`
845///   - `https://github.com/kkollsga/kglite.git` → `kkollsga/kglite`
846///
847/// Returns `None` for a non-git directory, a missing `origin` remote, or
848/// a non-GitHub remote — so the GitHub tools fall back to their existing
849/// empty-default path (ask the caller for `repo_name`).
850fn parse_origin_repo(root: &Path) -> Option<String> {
851    let out = Command::new("git")
852        .arg("-C")
853        .arg(root)
854        .args(["remote", "get-url", "origin"])
855        .output()
856        .ok()?;
857    if !out.status.success() {
858        return None;
859    }
860    let url = String::from_utf8(out.stdout).ok()?;
861    parse_github_remote(url.trim())
862}
863
864/// Pure-string half of [`parse_origin_repo`]: turn a GitHub remote URL
865/// into `org/repo`, or `None` if it isn't a recognisable GitHub remote.
866fn parse_github_remote(url: &str) -> Option<String> {
867    // Accept both SSH (`git@github.com:org/repo`) and HTTPS
868    // (`https://github.com/org/repo`) forms; everything after the host
869    // separator is the path.
870    let path = url
871        .strip_prefix("git@github.com:")
872        .or_else(|| url.strip_prefix("https://github.com/"))
873        .or_else(|| url.strip_prefix("http://github.com/"))
874        .or_else(|| url.strip_prefix("ssh://git@github.com/"))?;
875    let path = path.strip_suffix(".git").unwrap_or(path);
876    let path = path.trim_end_matches('/');
877    // Must be exactly `org/repo` — both segments non-empty, one slash.
878    let mut parts = path.split('/');
879    let org = parts.next().filter(|s| !s.is_empty())?;
880    let repo = parts.next().filter(|s| !s.is_empty())?;
881    if parts.next().is_some() {
882        return None;
883    }
884    Some(format!("{org}/{repo}"))
885}
886
887/// Cheap recursive content fingerprint of a directory tree. Walks files
888/// (respecting common ignore patterns) and folds `(path, mtime, len)`
889/// into a 64-bit hash, then hex-formats it. Good enough to detect
890/// "did anything change?" for auto-rebuild gating — not cryptographic.
891fn fingerprint_dir(root: &Path) -> String {
892    use std::hash::{Hash, Hasher};
893    let mut hasher = std::collections::hash_map::DefaultHasher::new();
894    let walker = ignore::WalkBuilder::new(root)
895        .standard_filters(true)
896        .hidden(true)
897        .git_ignore(true)
898        .build();
899    for entry in walker.flatten() {
900        if !entry.path().is_file() {
901            continue;
902        }
903        let Ok(meta) = entry.metadata() else { continue };
904        let mtime = meta
905            .modified()
906            .ok()
907            .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
908            .map(|d| d.as_secs())
909            .unwrap_or(0);
910        entry.path().to_string_lossy().hash(&mut hasher);
911        mtime.hash(&mut hasher);
912        meta.len().hash(&mut hasher);
913    }
914    format!("local-{:016x}", hasher.finish())
915}
916
917fn git_rev_parse(repo_path: &Path, refspec: &str) -> Result<String> {
918    let out = Command::new("git")
919        .args(["rev-parse", refspec])
920        .current_dir(repo_path)
921        .output()
922        .context("git rev-parse failed")?;
923    Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
924}
925
926fn now_iso() -> String {
927    format_iso(SystemTime::now())
928}
929
930fn format_iso(t: SystemTime) -> String {
931    let secs = t
932        .duration_since(SystemTime::UNIX_EPOCH)
933        .map(|d| d.as_secs())
934        .unwrap_or(0);
935    // Lightweight RFC3339-ish formatter. Drop sub-second precision; matches Python isoformat(timespec=seconds).
936    chrono_lite::format_secs(secs)
937}
938
939fn parse_iso(s: &str) -> Option<SystemTime> {
940    let secs = chrono_lite::parse_secs(s)?;
941    SystemTime::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(secs))
942}
943
944fn relative_time(iso: &str) -> String {
945    let Some(t) = parse_iso(iso) else {
946        return "unknown".to_string();
947    };
948    let now = SystemTime::now();
949    let delta = now.duration_since(t).unwrap_or_default().as_secs();
950    if delta < 3600 {
951        "just now".to_string()
952    } else if delta < 86_400 {
953        format!("{}h ago", delta / 3600)
954    } else {
955        format!("{}d ago", delta / 86_400)
956    }
957}
958
959/// Tiny self-contained ISO-8601 (seconds-precision) formatter so we
960/// don't pull in `chrono` for a handful of timestamps.
961mod chrono_lite {
962    pub fn format_secs(secs: u64) -> String {
963        // Civil-from-days algorithm (Howard Hinnant). Output: YYYY-MM-DDTHH:MM:SS.
964        let days = (secs / 86_400) as i64;
965        let time = secs % 86_400;
966        let (y, mo, d) = days_to_civil(days + 719_468);
967        let h = time / 3600;
968        let m = (time / 60) % 60;
969        let s = time % 60;
970        format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}")
971    }
972
973    pub fn parse_secs(s: &str) -> Option<u64> {
974        // Accept "YYYY-MM-DDTHH:MM:SS" (no zone) — same shape as format_secs output
975        // and Python's datetime.isoformat(timespec="seconds").
976        let bytes = s.as_bytes();
977        if bytes.len() < 19 {
978            return None;
979        }
980        let y: i64 = s.get(0..4)?.parse().ok()?;
981        let mo: u32 = s.get(5..7)?.parse().ok()?;
982        let d: u32 = s.get(8..10)?.parse().ok()?;
983        let h: u64 = s.get(11..13)?.parse().ok()?;
984        let m: u64 = s.get(14..16)?.parse().ok()?;
985        let sc: u64 = s.get(17..19)?.parse().ok()?;
986        let days = civil_to_days(y, mo, d) - 719_468;
987        Some((days * 86_400) as u64 + h * 3600 + m * 60 + sc)
988    }
989
990    fn days_to_civil(z: i64) -> (i64, u32, u32) {
991        let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
992        let doe = (z - era * 146_097) as u64;
993        let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
994        let y = (yoe as i64) + era * 400;
995        let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
996        let mp = (5 * doy + 2) / 153;
997        let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
998        let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
999        let y = if m <= 2 { y + 1 } else { y };
1000        (y, m, d)
1001    }
1002
1003    fn civil_to_days(y: i64, m: u32, d: u32) -> i64 {
1004        let y = if m <= 2 { y - 1 } else { y };
1005        let era = if y >= 0 { y } else { y - 399 } / 400;
1006        let yoe = (y - era * 400) as u64;
1007        let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) as u64 + 2) / 5 + d as u64 - 1;
1008        let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1009        era * 146_097 + doe as i64
1010    }
1011}
1012
1013// silences unused-import-when-helper-only-via-json! macro check.
1014#[allow(dead_code)]
1015fn _json_keepalive() {
1016    let _ = json!({});
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022
1023    #[test]
1024    fn validates_repo_names() {
1025        assert!(validate_repo_name("pydata/xarray").is_ok());
1026        assert!(validate_repo_name("my-org.x/repo_v2").is_ok());
1027        assert!(validate_repo_name("xarray").is_err());
1028        assert!(validate_repo_name("a/b/c").is_err());
1029        assert!(validate_repo_name("foo/bar; rm -rf").is_err());
1030    }
1031
1032    #[test]
1033    fn open_creates_layout() {
1034        let dir = tempfile::tempdir().unwrap();
1035        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1036        assert!(ws.repos_dir().is_dir());
1037    }
1038
1039    #[test]
1040    fn empty_list() {
1041        let dir = tempfile::tempdir().unwrap();
1042        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1043        let out = ws.repo_management(None, false, false, false);
1044        assert!(out.contains("No repos cloned yet"));
1045    }
1046
1047    #[test]
1048    fn invalid_repo_name_rejected() {
1049        let dir = tempfile::tempdir().unwrap();
1050        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1051        let out = ws.repo_management(Some("bad name with spaces"), false, false, false);
1052        assert!(out.contains("Invalid repo name"));
1053    }
1054
1055    #[test]
1056    fn delete_unknown() {
1057        let dir = tempfile::tempdir().unwrap();
1058        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1059        let out = ws.repo_management(Some("nope/none"), true, false, false);
1060        assert!(out.contains("Nothing to delete"));
1061    }
1062
1063    #[test]
1064    fn iso_round_trip() {
1065        let now = SystemTime::now()
1066            .duration_since(SystemTime::UNIX_EPOCH)
1067            .unwrap()
1068            .as_secs();
1069        let s = chrono_lite::format_secs(now);
1070        let back = chrono_lite::parse_secs(&s).unwrap();
1071        assert_eq!(now, back);
1072    }
1073
1074    #[test]
1075    fn last_built_sha_round_trip() {
1076        let dir = tempfile::tempdir().unwrap();
1077        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1078        // Seed an inventory entry directly (clone_or_update needs git).
1079        ws.bump_access("acme/widgets", "cloned");
1080        assert_eq!(ws.last_built_sha("acme/widgets"), None);
1081        ws.record_built_sha("acme/widgets", "abc1234deadbeef");
1082        assert_eq!(
1083            ws.last_built_sha("acme/widgets").as_deref(),
1084            Some("abc1234deadbeef")
1085        );
1086        // Survives an Workspace::open re-read (proves persistence).
1087        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1088        assert_eq!(
1089            ws2.last_built_sha("acme/widgets").as_deref(),
1090            Some("abc1234deadbeef")
1091        );
1092    }
1093
1094    #[test]
1095    fn inventory_loads_legacy_entries_without_sha_field() {
1096        let dir = tempfile::tempdir().unwrap();
1097        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1098        // Hand-craft an old-style inventory.json without `last_built_sha`.
1099        let legacy = r#"{
1100            "old/repo": {
1101                "cloned_at": "2024-01-01T00:00:00",
1102                "last_accessed": "2024-01-01T00:00:00",
1103                "access_count": 5,
1104                "stale": false
1105            }
1106        }"#;
1107        std::fs::write(dir.path().join("inventory.json"), legacy).unwrap();
1108        // Re-open and confirm graceful read.
1109        let ws2 = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1110        assert_eq!(ws2.last_built_sha("old/repo"), None);
1111        let _ = ws;
1112    }
1113
1114    #[test]
1115    fn auto_rebuild_gate_skips_when_sha_matches() {
1116        use std::sync::atomic::{AtomicUsize, Ordering};
1117        let dir = tempfile::tempdir().unwrap();
1118        let calls = Arc::new(AtomicUsize::new(0));
1119        let calls_h = calls.clone();
1120        let hook: PostActivateHook = Arc::new(move |_path, _name| {
1121            calls_h.fetch_add(1, Ordering::SeqCst);
1122            Ok(())
1123        });
1124        // Build a workspace pointing at a tempdir with a fake repo dir,
1125        // then simulate consecutive activates. We can't drive clone_or_update
1126        // without git, so test the gating directly by tracking the SHA
1127        // record-then-re-record case via Workspace::record_built_sha +
1128        // last_built_sha — the same predicate `activate` uses.
1129        let ws = Workspace::open(dir.path().to_path_buf(), 7, Some(hook)).unwrap();
1130        // Seed inventory entry + initial sha record.
1131        ws.bump_access("acme/widgets", "cloned");
1132        ws.record_built_sha("acme/widgets", "sha_one");
1133        assert_eq!(
1134            ws.last_built_sha("acme/widgets").as_deref(),
1135            Some("sha_one")
1136        );
1137        // Repeated record with the same value is idempotent (gating
1138        // logic uses last_built_sha as the source of truth).
1139        ws.record_built_sha("acme/widgets", "sha_one");
1140        assert_eq!(
1141            ws.last_built_sha("acme/widgets").as_deref(),
1142            Some("sha_one")
1143        );
1144        // No hook calls have been driven directly — this test exercises
1145        // the persistence path that the gate consults.
1146        assert_eq!(calls.load(Ordering::SeqCst), 0);
1147    }
1148
1149    #[test]
1150    fn local_workspace_binds_root_immediately() {
1151        let dir = tempfile::tempdir().unwrap();
1152        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1153        assert_eq!(ws.kind(), WorkspaceKind::Local);
1154        assert!(ws.active_repo_path().is_some());
1155        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1156    }
1157
1158    #[test]
1159    fn local_workspace_rejects_github_ops() {
1160        let dir = tempfile::tempdir().unwrap();
1161        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1162        let out = ws.repo_management(Some("acme/widgets"), false, false, false);
1163        assert!(out.contains("does not accept a repo name"));
1164        let out = ws.repo_management(None, true, false, false);
1165        assert!(out.contains("does not support `delete`"));
1166    }
1167
1168    #[test]
1169    fn local_workspace_update_rebuilds() {
1170        use std::sync::atomic::{AtomicUsize, Ordering};
1171        let dir = tempfile::tempdir().unwrap();
1172        // Drop a file so the fingerprint has something to hash.
1173        std::fs::write(dir.path().join("x.txt"), b"hi").unwrap();
1174        let calls = Arc::new(AtomicUsize::new(0));
1175        let calls_h = calls.clone();
1176        let hook: PostActivateHook = Arc::new(move |_p, _n| {
1177            calls_h.fetch_add(1, Ordering::SeqCst);
1178            Ok(())
1179        });
1180        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1181        // First update: nothing built yet → hook fires.
1182        let _ = ws.repo_management(None, false, true, false);
1183        assert_eq!(calls.load(Ordering::SeqCst), 1);
1184        // Second update without changes → SHA matches → hook skipped.
1185        let out = ws.repo_management(None, false, true, false);
1186        assert_eq!(
1187            calls.load(Ordering::SeqCst),
1188            1,
1189            "auto-rebuild gate must skip"
1190        );
1191        assert!(out.contains("build skipped"));
1192    }
1193
1194    #[test]
1195    fn parses_github_remote_forms() {
1196        assert_eq!(
1197            parse_github_remote("git@github.com:kkollsga/kglite.git").as_deref(),
1198            Some("kkollsga/kglite")
1199        );
1200        assert_eq!(
1201            parse_github_remote("https://github.com/kkollsga/kglite.git").as_deref(),
1202            Some("kkollsga/kglite")
1203        );
1204        // No .git suffix, trailing slash.
1205        assert_eq!(
1206            parse_github_remote("https://github.com/acme/widget/").as_deref(),
1207            Some("acme/widget")
1208        );
1209        assert_eq!(
1210            parse_github_remote("ssh://git@github.com/acme/widget.git").as_deref(),
1211            Some("acme/widget")
1212        );
1213        // Non-github / malformed → None.
1214        assert_eq!(
1215            parse_github_remote("https://gitlab.com/acme/widget.git"),
1216            None
1217        );
1218        assert_eq!(parse_github_remote("git@github.com:acme.git"), None);
1219        assert_eq!(parse_github_remote("not a url"), None);
1220    }
1221
1222    #[test]
1223    fn local_default_github_repo_uses_origin_remote() {
1224        let dir = tempfile::tempdir().unwrap();
1225        let root = dir.path();
1226        // Stand up a real git repo with a faked origin so default_github_repo
1227        // exercises the actual `git remote get-url` path.
1228        let git = |args: &[&str]| {
1229            Command::new("git")
1230                .arg("-C")
1231                .arg(root)
1232                .args(args)
1233                .output()
1234                .unwrap()
1235        };
1236        if !git(&["init"]).status.success() {
1237            // git unavailable in this environment — skip rather than fail.
1238            return;
1239        }
1240        git(&[
1241            "remote",
1242            "add",
1243            "origin",
1244            "https://github.com/acme/widget.git",
1245        ]);
1246        let ws = Workspace::open_local(root.to_path_buf(), None).unwrap();
1247        assert_eq!(
1248            ws.default_github_repo().as_deref(),
1249            Some("acme/widget"),
1250            "local default repo must come from the origin remote, not the inventory key"
1251        );
1252        // The inventory key remains the synthetic local name.
1253        assert!(ws.active_repo_name().unwrap().starts_with("local/"));
1254    }
1255
1256    #[test]
1257    fn local_default_github_repo_none_without_remote() {
1258        let dir = tempfile::tempdir().unwrap();
1259        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1260        // No git remote → None, and crucially NOT Some("local/<dir>").
1261        let def = ws.default_github_repo();
1262        assert!(
1263            def.is_none(),
1264            "expected None for a non-git local root, got {def:?}"
1265        );
1266    }
1267
1268    #[test]
1269    fn set_root_dir_only_in_local_mode() {
1270        let dir = tempfile::tempdir().unwrap();
1271        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1272        let out = ws.set_root_dir(dir.path());
1273        assert!(out.contains("only valid in local-workspace"));
1274    }
1275
1276    #[test]
1277    fn update_with_no_active_repo() {
1278        let dir = tempfile::tempdir().unwrap();
1279        let ws = Workspace::open(dir.path().to_path_buf(), 7, None).unwrap();
1280        let out = ws.repo_management(None, false, true, false);
1281        assert!(out.contains("No active repository"));
1282    }
1283
1284    #[test]
1285    fn set_root_dir_updates_active_path() {
1286        let dir = tempfile::tempdir().unwrap();
1287        let child = dir.path().join("child");
1288        std::fs::create_dir_all(&child).unwrap();
1289        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1290        let _ = ws.set_root_dir(&child);
1291        assert_eq!(
1292            ws.active_repo_path().unwrap(),
1293            child.canonicalize().unwrap(),
1294            "set_root_dir didn't update active_repo_path"
1295        );
1296    }
1297
1298    #[test]
1299    fn set_root_dir_post_activate_fires_against_new_root() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let child = dir.path().join("child");
1302        std::fs::create_dir_all(&child).unwrap();
1303        std::fs::write(child.join("a.txt"), b"hi").unwrap();
1304        let seen_path: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1305        let seen = seen_path.clone();
1306        let hook: PostActivateHook = Arc::new(move |p, _n| {
1307            *seen.lock().unwrap() = Some(p.to_path_buf());
1308            Ok(())
1309        });
1310        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(hook)).unwrap();
1311        let _ = ws.set_root_dir(&child);
1312        assert_eq!(
1313            seen_path.lock().unwrap().clone().unwrap(),
1314            child.canonicalize().unwrap(),
1315            "post_activate hook saw the wrong root after set_root_dir"
1316        );
1317    }
1318
1319    #[test]
1320    fn activation_summary_appended_to_activate_message() {
1321        let dir = tempfile::tempdir().unwrap();
1322        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1323        let summary: ActivationSummaryHook =
1324            Arc::new(|_p, _n| Some("Graph ready: 3 Functions.".to_string()));
1325        let ws = Workspace::open_local(dir.path().to_path_buf(), None)
1326            .unwrap()
1327            .with_activation_summary(summary);
1328        let out = ws.repo_management(None, false, true, false);
1329        assert!(
1330            out.contains("Graph ready: 3 Functions."),
1331            "activation message should include the summary; got: {out}"
1332        );
1333    }
1334
1335    #[test]
1336    fn activation_summary_absent_when_not_configured() {
1337        let dir = tempfile::tempdir().unwrap();
1338        std::fs::write(dir.path().join("a.txt"), b"x").unwrap();
1339        let ws = Workspace::open_local(dir.path().to_path_buf(), None).unwrap();
1340        let out = ws.repo_management(None, false, true, false);
1341        assert!(!out.contains("Graph ready"));
1342        assert!(
1343            out.contains(" at "),
1344            "expected the terse default message; got: {out}"
1345        );
1346    }
1347
1348    #[test]
1349    fn hook_fires_once_per_process_even_when_sha_matches() {
1350        use std::sync::atomic::{AtomicUsize, Ordering};
1351        // Local mode fingerprints the dir instead of a git SHA, so we can
1352        // drive the real `activate` path without git. A stable file keeps
1353        // the fingerprint constant across both simulated processes.
1354        let dir = tempfile::tempdir().unwrap();
1355        std::fs::write(dir.path().join("a.txt"), b"stable").unwrap();
1356
1357        let calls = Arc::new(AtomicUsize::new(0));
1358        let make_hook = || -> PostActivateHook {
1359            let c = calls.clone();
1360            Arc::new(move |_p, _n| {
1361                c.fetch_add(1, Ordering::SeqCst);
1362                Ok(())
1363            })
1364        };
1365
1366        // --- Process 1 ---------------------------------------------------
1367        let ws = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1368        // First activate (fingerprint not yet recorded) → hook fires.
1369        let _ = ws.repo_management(None, false, true, false);
1370        assert_eq!(
1371            calls.load(Ordering::SeqCst),
1372            1,
1373            "first activate must hydrate"
1374        );
1375        // Second activate, same process, unchanged fingerprint → cheap-skip.
1376        let out = ws.repo_management(None, false, true, false);
1377        assert_eq!(
1378            calls.load(Ordering::SeqCst),
1379            1,
1380            "repeat activate in same process must skip the hook"
1381        );
1382        assert!(
1383            out.contains("build skipped"),
1384            "expected skip suffix, got: {out}"
1385        );
1386        drop(ws);
1387
1388        // --- Process 2 (restart) ----------------------------------------
1389        // Same dir → inventory.json + last_built_sha persist, but the
1390        // in-memory hydration set does not. The first activate here must
1391        // re-fire the hook to rehydrate the consumer's in-memory state.
1392        let ws2 = Workspace::open_local(dir.path().to_path_buf(), Some(make_hook())).unwrap();
1393        assert!(
1394            ws2.last_built_sha(&ws2.active_repo_name().unwrap())
1395                .is_some(),
1396            "sanity: last_built_sha should survive the restart"
1397        );
1398        let _ = ws2.repo_management(None, false, true, false);
1399        assert_eq!(
1400            calls.load(Ordering::SeqCst),
1401            2,
1402            "fresh process must re-fire the hook even when the SHA matches"
1403        );
1404    }
1405
1406    #[test]
1407    fn a_b_a_swap_rebuilds_intervening_root() {
1408        // Regression for the single-slot-consumer stale-graph bug: an
1409        // A→B→A swap must rebuild A on the second bind, because activating
1410        // B overwrote the consumer's single live slot. Before the fix the
1411        // skip gate keyed off "A was hydrated at some point this process"
1412        // and wrongly skipped, leaving B's product live under A's name.
1413        use std::sync::atomic::{AtomicUsize, Ordering};
1414        let root = tempfile::tempdir().unwrap();
1415        let a = root.path().join("projA");
1416        let b = root.path().join("projB");
1417        std::fs::create_dir_all(&a).unwrap();
1418        std::fs::create_dir_all(&b).unwrap();
1419        // Stable, distinct contents so each root's fingerprint holds
1420        // constant across re-binds (so `action == "current"` on the
1421        // second bind of A — the exact condition the gate keys on).
1422        std::fs::write(a.join("a.txt"), b"alpha").unwrap();
1423        std::fs::write(b.join("b.txt"), b"beta").unwrap();
1424
1425        // The hook records which root it last built into the single slot,
1426        // mirroring a single-active-graph consumer.
1427        let built: Arc<std::sync::Mutex<Option<PathBuf>>> = Arc::new(Default::default());
1428        let built_h = built.clone();
1429        let calls = Arc::new(AtomicUsize::new(0));
1430        let calls_h = calls.clone();
1431        let hook: PostActivateHook = Arc::new(move |p, _n| {
1432            *built_h.lock().unwrap() = Some(p.to_path_buf());
1433            calls_h.fetch_add(1, Ordering::SeqCst);
1434            Ok(())
1435        });
1436
1437        let ws = Workspace::open_local(a.clone(), Some(hook)).unwrap();
1438        // open_local binds A but doesn't fire the hook; first set_root_dir(A)
1439        // hydrates it.
1440        let _ = ws.set_root_dir(&a);
1441        assert_eq!(calls.load(Ordering::SeqCst), 1, "first bind of A hydrates");
1442        assert_eq!(
1443            built.lock().unwrap().clone(),
1444            Some(a.canonicalize().unwrap())
1445        );
1446
1447        let _ = ws.set_root_dir(&b);
1448        assert_eq!(calls.load(Ordering::SeqCst), 2, "bind of B rebuilds");
1449        assert_eq!(
1450            built.lock().unwrap().clone(),
1451            Some(b.canonicalize().unwrap())
1452        );
1453
1454        // The bug: re-binding A must rebuild (slot currently holds B), not
1455        // cheap-skip. The single slot must end up holding A again.
1456        let out = ws.set_root_dir(&a);
1457        assert_eq!(
1458            calls.load(Ordering::SeqCst),
1459            3,
1460            "A→B→A must rebuild A; the intervening B overwrote the live slot"
1461        );
1462        assert!(
1463            !out.contains("build skipped"),
1464            "re-bind of a non-active root must not skip; got: {out}"
1465        );
1466        assert_eq!(
1467            built.lock().unwrap().clone(),
1468            Some(a.canonicalize().unwrap()),
1469            "after A→B→A the live slot must hold A, not B"
1470        );
1471
1472        // And an immediate re-bind of the *currently active* root (A→A)
1473        // still cheap-skips — the win the gate was added for is preserved.
1474        let out = ws.set_root_dir(&a);
1475        assert_eq!(
1476            calls.load(Ordering::SeqCst),
1477            3,
1478            "re-binding the already-active root must skip the hook"
1479        );
1480        assert!(
1481            out.contains("build skipped"),
1482            "expected skip suffix, got: {out}"
1483        );
1484    }
1485}