Skip to main content

lean_ctx/core/
project_link.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Local worktree → team project mapping (AP06-D01, owner decision 19.10.).
3//!
4//! Every checkout is its own local partition (`hash_project_root`, AP01).
5//! `lean-ctx project link` maps one partition to a team project id so team
6//! commands of that checkout address the project without `--project`. The
7//! mapping is local state of this machine: one JSON store in the data
8//! directory, keyed by partition, written atomically, bounded. It grants
9//! nothing — the team service still decides every request — and it is never
10//! the team artifact authority (`intelligence_runtime.team_artifact_projects`).
11
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17const STORE_FILE: &str = "project_links.json";
18const SCHEMA_VERSION: u32 = 1;
19/// Most links kept; a further link is refused, never evicts another.
20pub(crate) const MAX_LINKS: usize = 1024;
21/// Largest store read.
22const MAX_STORE_BYTES: u64 = 1024 * 1024;
23/// Longest label kept.
24pub const MAX_LABEL_CHARS: usize = 128;
25/// How long a writer waits for another one.
26const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
27
28pub const CHANGE_WITH: &str = "lean-ctx project link --project <id>";
29pub const REMOVE_WITH: &str = "lean-ctx project unlink";
30
31/// One checkout's link. Fields that are not known locally stay `None` and
32/// are left out of every answer rather than invented.
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct ProjectLink {
35    pub workspace_root: String,
36    pub team_project_id: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub organization_id: Option<String>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub label: Option<String>,
41    /// RFC 3339 UTC, whole seconds.
42    pub linked_at: String,
43}
44
45impl ProjectLink {
46    /// The `project.mapping` object of `GET /api/scope` and of
47    /// `lean-ctx project status --json`.
48    pub(crate) fn mapping_json(&self) -> serde_json::Value {
49        let mut mapping = serde_json::Map::new();
50        mapping.insert("state".into(), "linked".into());
51        mapping.insert(
52            "team_project_id".into(),
53            self.team_project_id.clone().into(),
54        );
55        if let Some(organization) = &self.organization_id {
56            mapping.insert("organization_id".into(), organization.clone().into());
57        }
58        if let Some(label) = &self.label {
59            mapping.insert("label".into(), label.clone().into());
60        }
61        mapping.insert("linked_at".into(), self.linked_at.clone().into());
62        mapping.insert("change_with".into(), CHANGE_WITH.into());
63        mapping.insert("remove_with".into(), REMOVE_WITH.into());
64        serde_json::Value::Object(mapping)
65    }
66}
67
68/// `project.mapping` for `root`: the link object, or `"unmapped"`.
69pub fn mapping_json(root: &str) -> serde_json::Value {
70    link_for_root(root).map_or_else(|| "unmapped".into(), |link| link.mapping_json())
71}
72
73#[derive(Debug, Default, Serialize, Deserialize)]
74struct Store {
75    schema_version: u32,
76    /// Partition id → link; ordered, so the file is byte-stable.
77    links: BTreeMap<String, ProjectLink>,
78}
79
80fn store_path(dir: &Path) -> PathBuf {
81    dir.join(STORE_FILE)
82}
83
84/// The partition of `root` (AP03): the hash of the path as given, and of its
85/// canonical form when that differs, so a lookup finds a link made through
86/// another spelling of the same directory.
87fn partitions(root: &str) -> Vec<String> {
88    let mut keys = vec![crate::core::project_hash::hash_project_root(root)];
89    let canonical = crate::core::pathutil::safe_canonicalize_or_self(Path::new(root))
90        .to_string_lossy()
91        .into_owned();
92    let other = crate::core::project_hash::hash_project_root(&canonical);
93    if !keys.contains(&other) {
94        keys.push(other);
95    }
96    keys
97}
98
99fn read_store(dir: &Path) -> Result<Store, String> {
100    use std::io::Read as _;
101    let path = store_path(dir);
102    let file = match std::fs::File::open(&path) {
103        Ok(file) => file,
104        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Store::default()),
105        Err(error) => return Err(format!("{}: {error}", path.display())),
106    };
107    let mut text = String::new();
108    file.take(MAX_STORE_BYTES + 1)
109        .read_to_string(&mut text)
110        .map_err(|error| format!("{}: {error}", path.display()))?;
111    if text.len() as u64 > MAX_STORE_BYTES {
112        return Err(format!("{} exceeds its size bound", path.display()));
113    }
114    let store: Store = serde_json::from_str(&text)
115        .map_err(|error| format!("{} is not a link store: {error}", path.display()))?;
116    if store.schema_version != SCHEMA_VERSION || store.links.len() > MAX_LINKS {
117        return Err(format!("{} is not a supported link store", path.display()));
118    }
119    Ok(store)
120}
121
122fn write_store(dir: &Path, store: &Store) -> Result<(), String> {
123    std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
124    let bytes = serde_json::to_vec_pretty(store).map_err(|error| error.to_string())?;
125    #[cfg(unix)]
126    let permissions = {
127        use std::os::unix::fs::PermissionsExt as _;
128        Some(std::fs::Permissions::from_mode(0o600))
129    };
130    #[cfg(not(unix))]
131    let permissions = None;
132    crate::core::atomic_fs::try_atomic_write(&store_path(dir), &bytes, permissions.as_ref())
133        .map_err(|error| format!("could not write the link store: {error}"))
134}
135
136/// Run `change` on the store under an exclusive lock with a bounded wait,
137/// and write the result.
138fn update<T>(
139    dir: &Path,
140    change: impl FnOnce(&mut Store) -> Result<T, String>,
141) -> Result<T, String> {
142    use fs2::FileExt as _;
143    std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
144    let lock = std::fs::OpenOptions::new()
145        .create(true)
146        .truncate(false)
147        .write(true)
148        .open(dir.join(format!("{STORE_FILE}.lock")))
149        .map_err(|error| format!("could not lock the link store: {error}"))?;
150    let deadline = std::time::Instant::now() + LOCK_WAIT;
151    while lock.try_lock_exclusive().is_err() {
152        if std::time::Instant::now() >= deadline {
153            return Err("the link store is busy; try again".to_owned());
154        }
155        std::thread::sleep(std::time::Duration::from_millis(25));
156    }
157    let mut store = read_store(dir)?;
158    store.schema_version = SCHEMA_VERSION;
159    let answer = change(&mut store)?;
160    write_store(dir, &store)?;
161    drop(lock);
162    Ok(answer)
163}
164
165fn data_dir() -> Result<PathBuf, String> {
166    crate::core::data_dir::lean_ctx_data_dir()
167}
168
169/// The link of the checkout at `root`, if any. An unreadable store reads as
170/// no link here; `link`/`unlink` report it.
171pub fn link_for_root(root: &str) -> Option<ProjectLink> {
172    link_in(&data_dir().ok()?, root)
173}
174
175fn link_in(dir: &Path, root: &str) -> Option<ProjectLink> {
176    let store = read_store(dir).ok()?;
177    partitions(root)
178        .iter()
179        .find_map(|key| store.links.get(key).cloned())
180}
181
182/// The other checkouts linked to the same team project (and organization)
183/// as the checkout at `root`, with that link: the checkouts whose
184/// `convention` facts `root` is served (AP01 §4). Ordered by partition, so
185/// a reply built from them is byte-stable. `None` when `root` is unlinked.
186pub fn linked_checkouts(root: &str) -> Option<(ProjectLink, Vec<String>)> {
187    linked_checkouts_in(&data_dir().ok()?, root)
188}
189
190fn linked_checkouts_in(dir: &Path, root: &str) -> Option<(ProjectLink, Vec<String>)> {
191    let store = read_store(dir).ok()?;
192    let own = partitions(root);
193    let link = own.iter().find_map(|key| store.links.get(key)).cloned()?;
194    let others = store
195        .links
196        .iter()
197        .filter(|(key, other)| {
198            !own.contains(key)
199                && other.team_project_id == link.team_project_id
200                && other.organization_id == link.organization_id
201        })
202        .map(|(_, other)| other.workspace_root.clone())
203        .collect();
204    Some((link, others))
205}
206
207/// Link the checkout at `link.workspace_root`; returns the link it replaced.
208pub fn link(link: ProjectLink) -> Result<Option<ProjectLink>, String> {
209    link_into(&data_dir()?, link)
210}
211
212fn link_into(dir: &Path, link: ProjectLink) -> Result<Option<ProjectLink>, String> {
213    let keys = partitions(&link.workspace_root);
214    update(dir, |store| {
215        let previous = keys.iter().find_map(|key| store.links.remove(key));
216        if previous.is_none() && store.links.len() >= MAX_LINKS {
217            return Err(format!(
218                "{MAX_LINKS} checkouts are linked already; unlink one first"
219            ));
220        }
221        store.links.insert(keys[0].clone(), link);
222        Ok(previous)
223    })
224}
225
226/// Remove the link of the checkout at `root`; returns it.
227pub fn unlink(root: &str) -> Result<Option<ProjectLink>, String> {
228    unlink_in(&data_dir()?, root)
229}
230
231fn unlink_in(dir: &Path, root: &str) -> Result<Option<ProjectLink>, String> {
232    let keys = partitions(root);
233    update(dir, |store| {
234        Ok(keys.iter().find_map(|key| store.links.remove(key)))
235    })
236}
237
238/// `now` as a link time: RFC 3339 UTC, whole seconds.
239pub fn link_time() -> String {
240    chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn sample(root: &str, project: &str) -> ProjectLink {
248        ProjectLink {
249            workspace_root: root.to_owned(),
250            team_project_id: project.to_owned(),
251            organization_id: None,
252            label: None,
253            linked_at: "2026-10-19T10:00:00Z".to_owned(),
254        }
255    }
256
257    #[test]
258    fn link_relink_unlink_by_partition() {
259        let store = tempfile::tempdir().unwrap();
260        let a = tempfile::tempdir().unwrap();
261        let b = tempfile::tempdir().unwrap();
262        let a_root = a.path().to_str().unwrap();
263        let b_root = b.path().to_str().unwrap();
264
265        assert!(link_in(store.path(), a_root).is_none());
266        assert_eq!(link_into(store.path(), sample(a_root, "gateway")), Ok(None));
267        // Another worktree is another partition: not linked by the first.
268        assert!(link_in(store.path(), b_root).is_none());
269        assert_eq!(
270            link_in(store.path(), a_root).unwrap().team_project_id,
271            "gateway"
272        );
273
274        let replaced = link_into(store.path(), sample(a_root, "other")).unwrap();
275        assert_eq!(replaced.unwrap().team_project_id, "gateway");
276        assert_eq!(read_store(store.path()).unwrap().links.len(), 1);
277
278        assert_eq!(
279            unlink_in(store.path(), a_root)
280                .unwrap()
281                .unwrap()
282                .team_project_id,
283            "other"
284        );
285        assert!(link_in(store.path(), a_root).is_none());
286        assert_eq!(unlink_in(store.path(), a_root), Ok(None));
287    }
288
289    #[test]
290    fn mapping_omits_unknown_fields_and_is_byte_stable() {
291        let mut link = sample("/w", "gateway");
292        let bare = link.mapping_json();
293        assert!(bare.get("organization_id").is_none());
294        assert!(bare.get("label").is_none());
295        link.organization_id = Some("6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f".to_owned());
296        link.label = Some("Gateway".to_owned());
297        assert_eq!(
298            link.mapping_json().to_string(),
299            serde_json::json!({
300                "state": "linked",
301                "team_project_id": "gateway",
302                "organization_id": "6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f",
303                "label": "Gateway",
304                "linked_at": "2026-10-19T10:00:00Z",
305                "change_with": "lean-ctx project link --project <id>",
306                "remove_with": "lean-ctx project unlink",
307            })
308            .to_string()
309        );
310    }
311
312    #[test]
313    fn a_damaged_store_is_never_overwritten() {
314        let store = tempfile::tempdir().unwrap();
315        std::fs::write(store_path(store.path()), "not json").unwrap();
316        assert!(link_into(store.path(), sample("/w", "gateway")).is_err());
317        assert_eq!(
318            std::fs::read_to_string(store_path(store.path())).unwrap(),
319            "not json"
320        );
321    }
322}