Skip to main content

packset_daemon/
workspace.rs

1//! Naming the workspace a client is in.
2//!
3//! The default is the normalized git remote, so two harnesses in one checkout
4//! agree without either being told. Asking git is the only I/O here; the rule
5//! it feeds lives in [`packset_core::identity`].
6
7use std::path::Path;
8use std::process::Command;
9
10use packset_core::identity::{self, Strategy};
11use serde_json::{json, Value};
12
13/// `git remote get-url origin` at `cwd`, or nothing.
14#[must_use]
15pub fn git_remote(cwd: &Path) -> Option<String> {
16    let out = Command::new("git")
17        .arg("-C")
18        .arg(cwd)
19        .args(["remote", "get-url", "origin"])
20        .output()
21        .ok()?;
22    if !out.status.success() {
23        return None;
24    }
25    let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
26    (!url.is_empty()).then_some(url)
27}
28
29/// The workspace name for a directory under one strategy.
30///
31/// A checkout with no remote falls back to its own path rather than to a shared
32/// name: two unrelated projects sharing `global` by accident is how a pack ends
33/// up answering one with the other's memory.
34#[must_use]
35pub fn resolve(cwd: &Path, strategy: Strategy, session: Option<&str>) -> String {
36    let root = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
37    match strategy {
38        Strategy::Global => "global".into(),
39        Strategy::PerSession => session.map_or_else(
40            || format!("dir:{}", root.display()),
41            |id| format!("session:{id}"),
42        ),
43        Strategy::PerDirectory => format!("dir:{}", root.display()),
44        Strategy::PerRepo => git_remote(&root)
45            .and_then(|url| identity::normalize_remote(&url).ok())
46            .unwrap_or_else(|| format!("dir:{}", root.display())),
47    }
48}
49
50/// The peer name for the person at the seat.
51#[must_use]
52pub fn user_peer(explicit: Option<&str>) -> String {
53    if let Some(name) = explicit.map(str::trim).filter(|n| !n.is_empty()) {
54        return name.to_string();
55    }
56    for var in ["GROK_INSIDE_USER_PEER", "USER", "LOGNAME"] {
57        if let Ok(value) = std::env::var(var) {
58            let value = value.trim();
59            if !value.is_empty() {
60                return value.to_string();
61            }
62        }
63    }
64    "unknown".into()
65}
66
67/// The identity record for one client.
68///
69/// # Errors
70///
71/// Returns a message when the harness name is empty.
72pub fn identity(
73    cwd: &Path,
74    strategy: Strategy,
75    harness: &str,
76    profile: Option<&str>,
77    session: Option<&str>,
78    turn: i64,
79    user: Option<&str>,
80) -> Result<Value, String> {
81    let root = std::fs::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf());
82    Ok(json!({
83        "schema": identity::SCHEMA,
84        "workspace": resolve(&root, strategy, session),
85        "workspace_strategy": strategy.as_str(),
86        "user_peer": user_peer(user),
87        "agent_peer": identity::agent_peer(harness, profile)?,
88        "harness": harness,
89        "profile": profile.unwrap_or(""),
90        "session_id": session,
91        "cwd": root.display().to_string(),
92        "turn": turn,
93    }))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn global_is_global_wherever_it_is_asked() {
102        assert_eq!(resolve(Path::new("/tmp"), Strategy::Global, None), "global");
103    }
104
105    #[test]
106    fn a_directory_strategy_names_the_directory() {
107        let name = resolve(Path::new("/tmp"), Strategy::PerDirectory, None);
108        assert!(name.starts_with("dir:"), "{name}");
109    }
110
111    #[test]
112    fn a_session_without_an_id_falls_back_to_the_directory() {
113        // Sharing one name across unrelated sessions would answer one with
114        // another's memory, so the fallback is the narrower thing.
115        let name = resolve(Path::new("/tmp"), Strategy::PerSession, None);
116        assert!(name.starts_with("dir:"), "{name}");
117        assert_eq!(
118            resolve(Path::new("/tmp"), Strategy::PerSession, Some("abc")),
119            "session:abc"
120        );
121    }
122
123    #[test]
124    fn a_checkout_with_no_remote_names_its_own_path() {
125        let dir = tempfile::tempdir().unwrap();
126        let name = resolve(dir.path(), Strategy::PerRepo, None);
127        assert!(name.starts_with("dir:"), "{name}");
128    }
129
130    #[test]
131    fn an_identity_carries_the_schema_and_the_peers() {
132        let dir = tempfile::tempdir().unwrap();
133        let ident = identity(
134            dir.path(),
135            Strategy::PerRepo,
136            "hermes",
137            None,
138            None,
139            0,
140            Some("rg"),
141        )
142        .unwrap();
143        assert_eq!(ident["schema"], json!(packset_core::identity::SCHEMA));
144        assert_eq!(ident["agent_peer"], json!("hermes"));
145        assert_eq!(ident["user_peer"], json!("rg"));
146        assert_eq!(ident["workspace_strategy"], json!("per-repo"));
147        assert_eq!(ident["turn"], json!(0));
148        assert!(identity(dir.path(), Strategy::PerRepo, "", None, None, 0, None).is_err());
149    }
150}