Skip to main content

git_paw/coordination/
inventory.rs

1//! Agent inventory + target validation for the supervisor `/agents` and
2//! `/tell` routing commands.
3//!
4//! The inventory is composed from two sources (design D1):
5//! - broker `GET /status` — `branch_id`, `status`, `last_seen`, `cli`;
6//! - `tmux list-panes` with `pane_current_path` — the live
7//!   `branch_id → pane_index` mapping (v0.5.0 doctrine: never assume pane
8//!   index ordering; resolve via the worktree path).
9//!
10//! The join, mode detection, target validation, and the freshness cache are
11//! all factored as library functions (design D6) so the v0.6.0 consumer (the
12//! `/tell` skill) and future consumers — notably the v1.0.0 MCP write tools'
13//! `publish_agent_feedback` — share one inventory + validation shape rather
14//! than re-implementing it.
15
16use std::collections::HashMap;
17use std::fmt;
18use std::io::{Read, Write};
19use std::net::TcpStream;
20use std::time::{Duration, Instant};
21
22use serde::Deserialize;
23
24use crate::error::PawError;
25
26/// Best-effort detected interaction mode of an agent's CLI pane.
27///
28/// Detection is heuristic (design D1 / D3): when the pane title and recent
29/// capture give no clear signal the mode is [`Mode::Unknown`], and `/tell`
30/// treats `unknown` (and `interactive`) as requiring the safe
31/// `agent.feedback` delivery path.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Mode {
34    /// The CLI is in accept-edits / auto-accept mode — safe for `send-keys`.
35    AcceptEdits,
36    /// The CLI is in an interactive prompt-per-action mode.
37    Interactive,
38    /// No clear signal; consumers fall back to the safe delivery mode.
39    Unknown,
40}
41
42impl Mode {
43    /// The kebab-case label used in the rendered inventory and learnings.
44    #[must_use]
45    pub fn as_str(self) -> &'static str {
46        match self {
47            Self::AcceptEdits => "accept-edits",
48            Self::Interactive => "interactive",
49            Self::Unknown => "unknown",
50        }
51    }
52}
53
54impl fmt::Display for Mode {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        f.write_str(self.as_str())
57    }
58}
59
60/// One agent's inventory record.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct AgentEntry {
63    /// Agent identifier as registered with the broker (slug form, e.g.
64    /// `feat-auth`). `/tell` accepts either the slug or the slash form.
65    pub branch_id: String,
66    /// Status label from the broker (`"working"`, `"done"`, `"blocked"`, …).
67    pub status: String,
68    /// Seconds since the agent was last seen, per the broker.
69    pub last_seen_seconds: u64,
70    /// CLI name running in the pane (`"claude"`, …), when known.
71    pub cli: Option<String>,
72    /// Best-effort detected interaction mode.
73    pub mode: Mode,
74    /// tmux pane index resolved via `pane_current_path`, when matched.
75    pub pane_index: Option<usize>,
76}
77
78/// A point-in-time inventory snapshot.
79#[derive(Debug, Clone)]
80pub struct AgentInventory {
81    /// One entry per agent the broker knows about, plus the supervisor row.
82    pub entries: Vec<AgentEntry>,
83    /// When this snapshot was built — used by [`InventoryCache`] freshness.
84    pub refreshed_at: Instant,
85}
86
87impl AgentInventory {
88    /// Looks up an entry by target identifier, matching either the slug
89    /// (`feat-auth`) or slash form (`feat/auth`).
90    #[must_use]
91    pub fn find(&self, target_id: &str) -> Option<&AgentEntry> {
92        let needle = normalize_id(target_id);
93        self.entries
94            .iter()
95            .find(|e| normalize_id(&e.branch_id) == needle)
96    }
97
98    /// The candidate target identifiers (every agent except the supervisor),
99    /// sorted for deterministic rendering.
100    #[must_use]
101    pub fn candidate_ids(&self) -> Vec<String> {
102        let mut ids: Vec<String> = self
103            .entries
104            .iter()
105            .filter(|e| e.branch_id != "supervisor")
106            .map(|e| e.branch_id.clone())
107            .collect();
108        ids.sort();
109        ids
110    }
111}
112
113/// Normalises an agent identifier for comparison: trims surrounding
114/// whitespace and treats the slash form (`feat/auth`) and slug form
115/// (`feat-auth`) as equivalent.
116fn normalize_id(id: &str) -> String {
117    id.trim().replace('/', "-")
118}
119
120/// One agent row parsed from the broker `/status` JSON.
121///
122/// Mirrors the public fields of `broker::AgentStatusEntry`; only the fields
123/// the inventory needs are deserialised. Kept `pub(crate)` so the join and
124/// its fixtures-based unit tests can construct rows directly.
125#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
126pub struct StatusAgent {
127    /// Agent identifier (slug form).
128    pub agent_id: String,
129    /// Status label.
130    #[serde(default)]
131    pub status: String,
132    /// Seconds since last seen.
133    #[serde(default)]
134    pub last_seen_seconds: u64,
135    /// CLI name; the `/status` endpoint emits an empty string when unknown,
136    /// which the join maps to `None`.
137    #[serde(default)]
138    pub cli: String,
139}
140
141#[derive(Debug, Deserialize)]
142struct StatusBody {
143    #[serde(default)]
144    agents: Vec<StatusAgent>,
145}
146
147/// Parses the broker `GET /status` JSON body into agent rows.
148///
149/// # Errors
150/// Returns [`PawError::SessionError`] when the body is not valid `/status`
151/// JSON.
152pub fn parse_status_agents(json: &str) -> Result<Vec<StatusAgent>, PawError> {
153    let body: StatusBody = serde_json::from_str(json)
154        .map_err(|e| PawError::SessionError(format!("broker /status parse error: {e}")))?;
155    Ok(body.agents)
156}
157
158/// Parses `tmux list-panes -F '#{pane_index} #{pane_current_path}'` output
159/// into `(pane_index, current_path)` pairs.
160///
161/// Lines that do not start with a numeric pane index are skipped. The path is
162/// the remainder of the line (paths never contain the separating space at the
163/// front, and may themselves contain spaces).
164#[must_use]
165pub fn parse_pane_paths(output: &str) -> Vec<(usize, String)> {
166    output
167        .lines()
168        .filter_map(|line| {
169            let line = line.trim_end();
170            let (idx, path) = line.split_once(' ')?;
171            let idx: usize = idx.trim().parse().ok()?;
172            Some((idx, path.to_string()))
173        })
174        .collect()
175}
176
177/// Resolves the pane index for `agent_id` from the `pane_current_path`
178/// mapping, per the v0.5.0 doctrine (match on the worktree path, never on
179/// index ordering).
180///
181/// The supervisor occupies pane 0 by construction; every other agent's
182/// worktree basename ends in `-<agent_id>` (e.g. `myproj-feat-auth` for
183/// `feat-auth`). The `-` prefix on the suffix prevents `feat-a` from matching
184/// `…-feat-api`.
185#[must_use]
186pub fn match_pane(agent_id: &str, pane_paths: &[(usize, String)]) -> Option<usize> {
187    if agent_id == "supervisor" {
188        return Some(0);
189    }
190    let suffix = format!("-{agent_id}");
191    pane_paths.iter().find_map(|(idx, path)| {
192        let base = path
193            .trim_end_matches('/')
194            .rsplit('/')
195            .next()
196            .unwrap_or(path);
197        (base == agent_id || base.ends_with(&suffix)).then_some(*idx)
198    })
199}
200
201/// Joins broker status rows with the tmux pane mapping and per-pane detected
202/// modes into inventory entries.
203///
204/// `modes` maps a pane index to its detected [`Mode`]; an agent whose pane is
205/// absent from the map (or unmatched) reports [`Mode::Unknown`].
206#[must_use]
207pub fn join_inventory<S: std::hash::BuildHasher>(
208    agents: Vec<StatusAgent>,
209    pane_paths: &[(usize, String)],
210    modes: &HashMap<usize, Mode, S>,
211) -> Vec<AgentEntry> {
212    agents
213        .into_iter()
214        .map(|a| {
215            let pane_index = match_pane(&a.agent_id, pane_paths);
216            let mode = pane_index
217                .and_then(|idx| modes.get(&idx).copied())
218                .unwrap_or(Mode::Unknown);
219            let cli = if a.cli.trim().is_empty() {
220                None
221            } else {
222                Some(a.cli)
223            };
224            AgentEntry {
225                branch_id: a.agent_id,
226                status: a.status,
227                last_seen_seconds: a.last_seen_seconds,
228                cli,
229                mode,
230                pane_index,
231            }
232        })
233        .collect()
234}
235
236/// Best-effort mode detection from an agent pane's title and recent capture.
237///
238/// Heuristic (design D1): an explicit accept-edits / bypass-permissions
239/// footer marks [`Mode::AcceptEdits`]; a visible interactive prompt marks
240/// [`Mode::Interactive`]; anything else is [`Mode::Unknown`]. The signal set
241/// is illustrative, not exhaustive — when in doubt the result is `Unknown`
242/// and consumers fall back to the safe delivery mode.
243#[must_use]
244pub fn detect_mode(pane_title: &str, capture: &str) -> Mode {
245    let hay = format!("{pane_title}\n{capture}").to_lowercase();
246    if hay.contains("accept edits")
247        || hay.contains("accept-edits")
248        || hay.contains("bypass permissions")
249    {
250        Mode::AcceptEdits
251    } else if hay.contains("? for shortcuts")
252        || hay.contains("do you want to proceed")
253        || hay.contains("do you want to allow")
254        || hay.contains("(y/n)")
255        || hay.contains("[y/n]")
256        || hay.contains("❯ 1. yes")
257    {
258        Mode::Interactive
259    } else {
260        Mode::Unknown
261    }
262}
263
264/// Rejection returned by [`validate_target`] for an unknown target.
265///
266/// This is the documented, stable error shape (design D6): every consumer —
267/// `/tell` today, the v1.0.0 MCP `publish_agent_feedback` later — surfaces the
268/// same `{ target, candidates }` rejection so unknown targets are refused
269/// consistently.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum ValidationError {
272    /// The named target is not in the inventory; `candidates` lists the
273    /// available agent identifiers.
274    UnknownTarget {
275        /// The rejected target identifier as typed by the user.
276        target: String,
277        /// The available agent identifiers (supervisor excluded), sorted.
278        candidates: Vec<String>,
279    },
280}
281
282impl fmt::Display for ValidationError {
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            Self::UnknownTarget { target, candidates } => {
286                write!(
287                    f,
288                    "unknown target `{target}`; available agents: {}",
289                    candidates.join(", ")
290                )
291            }
292        }
293    }
294}
295
296impl std::error::Error for ValidationError {}
297
298/// Validates a `/tell` target against the inventory.
299///
300/// Returns the matching [`AgentEntry`] (matching either slug or slash form),
301/// or a [`ValidationError::UnknownTarget`] carrying the candidate list so the
302/// caller can echo the available agents back to the user. This is the shared
303/// helper of design D6 — public so consumers outside the supervisor module
304/// can reuse the same validation semantics.
305///
306/// # Errors
307/// Returns [`ValidationError::UnknownTarget`] when no entry matches.
308pub fn validate_target<'a>(
309    inventory: &'a AgentInventory,
310    target_id: &str,
311) -> Result<&'a AgentEntry, ValidationError> {
312    inventory
313        .find(target_id)
314        .ok_or_else(|| ValidationError::UnknownTarget {
315            target: target_id.trim().to_string(),
316            candidates: inventory.candidate_ids(),
317        })
318}
319
320/// In-memory inventory cache with a freshness window (design D2).
321///
322/// Owned by the supervisor's sweep loop: the sweep stores a fresh snapshot at
323/// its cadence, and `/tell` / `/agents` reuse the cached snapshot while it is
324/// younger than `max_age`, rebuilding on demand only when stale. There is no
325/// on-disk cache — a supervisor restart produces a fresh inventory.
326#[derive(Debug)]
327pub struct InventoryCache {
328    snapshot: Option<AgentInventory>,
329    max_age: Duration,
330}
331
332impl InventoryCache {
333    /// Creates an empty cache with the given freshness window.
334    #[must_use]
335    pub fn new(max_age: Duration) -> Self {
336        Self {
337            snapshot: None,
338            max_age,
339        }
340    }
341
342    /// Creates an empty cache with a freshness window in seconds (the
343    /// `[supervisor.tell] inventory_max_age_seconds` config value).
344    #[must_use]
345    pub fn from_seconds(seconds: u64) -> Self {
346        Self::new(Duration::from_secs(seconds))
347    }
348
349    /// The configured freshness window.
350    #[must_use]
351    pub fn max_age(&self) -> Duration {
352        self.max_age
353    }
354
355    /// The cached snapshot, if any.
356    #[must_use]
357    pub fn snapshot(&self) -> Option<&AgentInventory> {
358        self.snapshot.as_ref()
359    }
360
361    /// Whether the cache holds a snapshot still within `max_age` as of `now`.
362    #[must_use]
363    pub fn is_fresh_at(&self, now: Instant) -> bool {
364        self.snapshot
365            .as_ref()
366            .is_some_and(|s| now.duration_since(s.refreshed_at) < self.max_age)
367    }
368
369    /// Stores a freshly-built snapshot (called by the sweep loop).
370    pub fn store(&mut self, snapshot: AgentInventory) {
371        self.snapshot = Some(snapshot);
372    }
373
374    /// Returns the cached snapshot when fresh as of `now`; otherwise calls
375    /// `refresh` to rebuild it, stores the result, and returns it.
376    ///
377    /// `refresh` is invoked at most once per stale lookup, so rapid
378    /// consecutive `/agents` within the freshness window trigger only one
379    /// rebuild (the broker is not re-polled while the snapshot is fresh).
380    ///
381    /// # Errors
382    /// Propagates any error from `refresh`; the previous snapshot (if any) is
383    /// left untouched on failure.
384    ///
385    /// # Panics
386    /// Does not panic in practice: when the cache is stale `refresh` populates
387    /// the snapshot before it is unwrapped, so the `Some` invariant holds.
388    pub fn get_or_refresh<F, E>(&mut self, now: Instant, refresh: F) -> Result<&AgentInventory, E>
389    where
390        F: FnOnce() -> Result<AgentInventory, E>,
391    {
392        if !self.is_fresh_at(now) {
393            let snapshot = refresh()?;
394            self.snapshot = Some(snapshot);
395        }
396        Ok(self
397            .snapshot
398            .as_ref()
399            .expect("snapshot present after refresh"))
400    }
401}
402
403/// Builds an inventory snapshot from the live broker and tmux session.
404///
405/// Polls broker `GET /status`, lists the session's panes with their
406/// `pane_current_path`, detects each pane's mode, and joins them into
407/// [`AgentEntry`] rows. The snapshot is stamped with the current [`Instant`]
408/// for cache freshness.
409///
410/// # Errors
411/// Returns [`PawError`] when the broker is unreachable, returns a non-200
412/// status, or emits unparseable `/status` JSON. tmux failures degrade
413/// gracefully: a pane listing that fails yields an empty mapping (all
414/// `pane_index` become `None`) rather than aborting the inventory.
415pub fn build_inventory(broker_url: &str, tmux_session: &str) -> Result<AgentInventory, PawError> {
416    let body = fetch_status_body(broker_url)?;
417    let agents = parse_status_agents(&body)?;
418    let pane_output = list_pane_paths(tmux_session).unwrap_or_default();
419    let pane_paths = parse_pane_paths(&pane_output);
420    let mut modes = HashMap::new();
421    for (idx, _) in &pane_paths {
422        modes.insert(*idx, detect_pane_mode(tmux_session, *idx));
423    }
424    let entries = join_inventory(agents, &pane_paths, &modes);
425    Ok(AgentInventory {
426        entries,
427        refreshed_at: Instant::now(),
428    })
429}
430
431/// Fetches the raw `GET /status` JSON body over a minimal HTTP/1.1 request.
432fn fetch_status_body(broker_url: &str) -> Result<String, PawError> {
433    let addr = broker_url.strip_prefix("http://").unwrap_or(broker_url);
434    let socket_addr = if let Ok(a) = addr.parse() {
435        a
436    } else {
437        use std::net::ToSocketAddrs;
438        addr.to_socket_addrs()
439            .map_err(|e| PawError::SessionError(format!("invalid broker address {addr}: {e}")))?
440            .next()
441            .ok_or_else(|| {
442                PawError::SessionError(format!("broker address {addr} resolved to no addrs"))
443            })?
444    };
445
446    let mut stream = TcpStream::connect_timeout(&socket_addr, Duration::from_millis(500))
447        .map_err(|e| PawError::SessionError(format!("failed to connect to broker: {e}")))?;
448    stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
449    stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
450
451    let request = format!("GET /status HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
452    stream
453        .write_all(request.as_bytes())
454        .map_err(|e| PawError::SessionError(format!("failed to write status request: {e}")))?;
455
456    let mut response = String::new();
457    let _ = stream.read_to_string(&mut response);
458
459    if !(response.starts_with("HTTP/1.1 200") || response.starts_with("HTTP/1.0 200")) {
460        return Err(PawError::SessionError(format!(
461            "broker /status returned non-200: {}",
462            response.lines().next().unwrap_or("<empty>")
463        )));
464    }
465
466    let body_start = response
467        .find("\r\n\r\n")
468        .map(|i| i + 4)
469        .ok_or_else(|| PawError::SessionError("malformed broker /status response".to_string()))?;
470    Ok(response[body_start..].to_string())
471}
472
473/// Runs `tmux list-panes` for the session, formatting each line as
474/// `<pane_index> <pane_current_path>`.
475fn list_pane_paths(session: &str) -> Result<String, PawError> {
476    let output = std::process::Command::new("tmux")
477        .args([
478            "list-panes",
479            "-t",
480            &format!("{session}:0"),
481            "-F",
482            "#{pane_index} #{pane_current_path}",
483        ])
484        .output()
485        .map_err(|e| PawError::SessionError(format!("tmux list-panes failed: {e}")))?;
486    if !output.status.success() {
487        return Err(PawError::SessionError(format!(
488            "tmux list-panes exited with {}",
489            output.status
490        )));
491    }
492    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
493}
494
495/// Captures a pane's title and recent content and detects its mode.
496fn detect_pane_mode(session: &str, pane_index: usize) -> Mode {
497    let title = std::process::Command::new("tmux")
498        .args([
499            "display-message",
500            "-t",
501            &format!("{session}:0.{pane_index}"),
502            "-p",
503            "#{pane_title}",
504        ])
505        .output()
506        .ok()
507        .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
508        .unwrap_or_default();
509    let capture =
510        crate::supervisor::permission_prompt::capture_pane(session, pane_index).unwrap_or_default();
511    detect_mode(&title, &capture)
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use std::cell::Cell;
518
519    const STATUS_JSON: &str = r#"{
520        "git_paw": true,
521        "version": "0.6.0",
522        "uptime_seconds": 42,
523        "agents": [
524            {"agent_id": "feat-auth", "cli": "claude", "status": "working", "last_seen_seconds": 3, "summary": ""},
525            {"agent_id": "feat-api", "cli": "", "status": "blocked", "last_seen_seconds": 90, "summary": ""},
526            {"agent_id": "supervisor", "cli": "claude", "status": "working", "last_seen_seconds": 1, "summary": ""}
527        ]
528    }"#;
529
530    fn fixture_inventory() -> AgentInventory {
531        let agents = parse_status_agents(STATUS_JSON).unwrap();
532        // Non-sequential pane mapping: feat-api on a lower index than feat-auth.
533        let panes = parse_pane_paths(
534            "0 /home/user/myproj\n1 /home/user/myproj-feat-api\n2 /home/user/myproj-feat-auth\n",
535        );
536        let mut modes = HashMap::new();
537        modes.insert(2usize, Mode::AcceptEdits);
538        let entries = join_inventory(agents, &panes, &modes);
539        AgentInventory {
540            entries,
541            refreshed_at: Instant::now(),
542        }
543    }
544
545    #[test]
546    fn parse_status_agents_reads_all_rows() {
547        let agents = parse_status_agents(STATUS_JSON).unwrap();
548        assert_eq!(agents.len(), 3);
549        assert_eq!(agents[0].agent_id, "feat-auth");
550        assert_eq!(agents[0].cli, "claude");
551        assert_eq!(agents[1].last_seen_seconds, 90);
552    }
553
554    #[test]
555    fn parse_pane_paths_handles_spaces_and_skips_garbage() {
556        let panes =
557            parse_pane_paths("0 /home/user/my proj\n1 /home/user/wt-feat-x\nnot-a-pane line\n");
558        assert_eq!(panes.len(), 2);
559        assert_eq!(panes[0], (0, "/home/user/my proj".to_string()));
560        assert_eq!(panes[1], (1, "/home/user/wt-feat-x".to_string()));
561    }
562
563    #[test]
564    fn pane_index_is_path_resolved_not_ordered() {
565        let inv = fixture_inventory();
566        let api = inv.find("feat-api").unwrap();
567        let auth = inv.find("feat-auth").unwrap();
568        // Resolution is by worktree path, NOT alphabetical / registration order:
569        // feat-api landed on pane 1, feat-auth on pane 2.
570        assert_eq!(api.pane_index, Some(1));
571        assert_eq!(auth.pane_index, Some(2));
572    }
573
574    #[test]
575    fn match_pane_does_not_partial_match_prefix() {
576        let panes = parse_pane_paths("1 /home/user/proj-feat-api\n");
577        // `feat-a` must NOT match `…-feat-api`.
578        assert_eq!(match_pane("feat-a", &panes), None);
579        assert_eq!(match_pane("feat-api", &panes), Some(1));
580    }
581
582    #[test]
583    fn supervisor_resolves_to_pane_zero() {
584        let inv = fixture_inventory();
585        let sup = inv.find("supervisor").unwrap();
586        assert_eq!(sup.pane_index, Some(0));
587    }
588
589    #[test]
590    fn empty_cli_maps_to_none() {
591        let inv = fixture_inventory();
592        assert_eq!(
593            inv.find("feat-auth").unwrap().cli.as_deref(),
594            Some("claude")
595        );
596        assert_eq!(inv.find("feat-api").unwrap().cli, None);
597    }
598
599    #[test]
600    fn agent_removed_mid_grid_drops_pane_index() {
601        // feat-api's pane was removed (middle-grid remove); its broker row
602        // lingers until the next sweep but no pane matches → pane_index None.
603        let agents = parse_status_agents(STATUS_JSON).unwrap();
604        let panes = parse_pane_paths("0 /home/user/myproj\n2 /home/user/myproj-feat-auth\n");
605        let entries = join_inventory(agents, &panes, &HashMap::new());
606        let inv = AgentInventory {
607            entries,
608            refreshed_at: Instant::now(),
609        };
610        assert_eq!(inv.find("feat-api").unwrap().pane_index, None);
611        assert_eq!(inv.find("feat-auth").unwrap().pane_index, Some(2));
612    }
613
614    #[test]
615    fn detect_mode_accept_edits() {
616        assert_eq!(
617            detect_mode("", "⏵⏵ accept edits on (shift+tab to cycle)"),
618            Mode::AcceptEdits
619        );
620        assert_eq!(
621            detect_mode("claude — bypass permissions", ""),
622            Mode::AcceptEdits
623        );
624    }
625
626    #[test]
627    fn detect_mode_interactive_prompt() {
628        assert_eq!(
629            detect_mode("", "Do you want to proceed?\n❯ 1. Yes"),
630            Mode::Interactive
631        );
632    }
633
634    #[test]
635    fn detect_mode_unknown_when_no_signal() {
636        assert_eq!(
637            detect_mode("", "Boondoggling… (esc to interrupt)"),
638            Mode::Unknown
639        );
640    }
641
642    #[test]
643    fn unknown_mode_signals_join_to_unknown() {
644        let inv = fixture_inventory();
645        // feat-api's pane had no detected mode in the modes map → Unknown.
646        assert_eq!(inv.find("feat-api").unwrap().mode, Mode::Unknown);
647        assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::AcceptEdits);
648    }
649
650    #[test]
651    fn validate_target_accepts_slug_and_slash_form() {
652        let inv = fixture_inventory();
653        assert!(validate_target(&inv, "feat-auth").is_ok());
654        // slash form resolves to the same slug entry
655        assert_eq!(
656            validate_target(&inv, "feat/auth").unwrap().branch_id,
657            "feat-auth"
658        );
659    }
660
661    #[test]
662    fn validate_target_unknown_returns_candidate_list() {
663        let inv = fixture_inventory();
664        let err = validate_target(&inv, "feat/ghost").unwrap_err();
665        match err {
666            ValidationError::UnknownTarget { target, candidates } => {
667                assert_eq!(target, "feat/ghost");
668                // supervisor excluded; sorted.
669                assert_eq!(
670                    candidates,
671                    vec!["feat-api".to_string(), "feat-auth".to_string()]
672                );
673            }
674        }
675    }
676
677    #[test]
678    fn validation_error_display_lists_candidates() {
679        let err = ValidationError::UnknownTarget {
680            target: "feat/ghost".to_string(),
681            candidates: vec!["feat/a".to_string(), "feat/b".to_string()],
682        };
683        let msg = err.to_string();
684        assert!(msg.contains("feat/ghost"));
685        assert!(msg.contains("feat/a, feat/b"), "got: {msg}");
686    }
687
688    // --- InventoryCache (design D2) ---
689
690    fn snapshot_now() -> AgentInventory {
691        AgentInventory {
692            entries: Vec::new(),
693            refreshed_at: Instant::now(),
694        }
695    }
696
697    #[test]
698    fn cache_starts_empty_and_not_fresh() {
699        let cache = InventoryCache::from_seconds(60);
700        assert!(cache.snapshot().is_none());
701        assert!(!cache.is_fresh_at(Instant::now()));
702    }
703
704    #[test]
705    fn rapid_lookups_within_window_refresh_once() {
706        let calls = Cell::new(0u32);
707        let mut cache = InventoryCache::from_seconds(60);
708        let refresh = || {
709            calls.set(calls.get() + 1);
710            Ok::<_, ()>(snapshot_now())
711        };
712        // Two consecutive lookups within the freshness window.
713        cache.get_or_refresh(Instant::now(), refresh).unwrap();
714        let refresh2 = || {
715            calls.set(calls.get() + 1);
716            Ok::<_, ()>(snapshot_now())
717        };
718        cache.get_or_refresh(Instant::now(), refresh2).unwrap();
719        assert_eq!(calls.get(), 1, "fresh cache must not re-poll the broker");
720    }
721
722    #[test]
723    fn stale_snapshot_triggers_refresh() {
724        let mut cache = InventoryCache::from_seconds(60);
725        // Seed a snapshot timestamped 2min in the past → stale at 60s max-age.
726        let stale = AgentInventory {
727            entries: Vec::new(),
728            refreshed_at: Instant::now()
729                .checked_sub(Duration::from_mins(2))
730                .expect("instant in range"),
731        };
732        cache.store(stale);
733        assert!(!cache.is_fresh_at(Instant::now()));
734
735        let calls = Cell::new(0u32);
736        cache
737            .get_or_refresh(Instant::now(), || {
738                calls.set(calls.get() + 1);
739                Ok::<_, ()>(snapshot_now())
740            })
741            .unwrap();
742        assert_eq!(calls.get(), 1, "stale cache must rebuild");
743        assert!(cache.is_fresh_at(Instant::now()));
744    }
745
746    // --- build_inventory end-to-end against a fake broker (IO orchestration) ---
747
748    /// Spawns a one-shot HTTP server that answers a single `GET /status` with
749    /// `body`, and returns its `http://addr` URL.
750    fn spawn_status_server(body: &'static str) -> String {
751        use std::net::TcpListener;
752        let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral port");
753        let addr = listener.local_addr().expect("local addr");
754        std::thread::spawn(move || {
755            if let Ok((mut stream, _)) = listener.accept() {
756                let mut buf = [0u8; 1024];
757                let _ = stream.read(&mut buf);
758                let resp = format!(
759                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
760                    body.len()
761                );
762                let _ = stream.write_all(resp.as_bytes());
763            }
764        });
765        format!("http://{addr}")
766    }
767
768    #[test]
769    fn build_inventory_against_fake_broker_no_tmux() {
770        let url = spawn_status_server(STATUS_JSON);
771        // The tmux session does not exist, so list-panes fails and every
772        // pane_index degrades to None (except the supervisor's pane-0 rule).
773        let inv = build_inventory(&url, "paw-nonexistent-xyz-123").expect("inventory builds");
774        assert_eq!(inv.entries.len(), 3);
775        assert_eq!(inv.find("feat-auth").unwrap().pane_index, None);
776        assert_eq!(inv.find("feat-auth").unwrap().mode, Mode::Unknown);
777        assert_eq!(inv.find("supervisor").unwrap().pane_index, Some(0));
778    }
779
780    #[test]
781    fn build_inventory_unreachable_broker_errors() {
782        // Port 1 on loopback refuses immediately → connect error propagates.
783        assert!(build_inventory("http://127.0.0.1:1", "x").is_err());
784    }
785
786    #[test]
787    fn parse_status_agents_rejects_garbage() {
788        assert!(parse_status_agents("not json at all").is_err());
789    }
790
791    #[test]
792    fn detect_pane_mode_helper_on_dead_session_is_unknown() {
793        // capture + title both fail for a nonexistent session/pane → Unknown.
794        assert_eq!(
795            detect_pane_mode("paw-nonexistent-xyz-123", 9),
796            Mode::Unknown
797        );
798    }
799}