1use 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;
19pub(crate) const MAX_LINKS: usize = 1024;
21const MAX_STORE_BYTES: u64 = 1024 * 1024;
23pub const MAX_LABEL_CHARS: usize = 128;
25const 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#[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 pub linked_at: String,
43}
44
45impl ProjectLink {
46 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
68pub 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 links: BTreeMap<String, ProjectLink>,
78}
79
80fn store_path(dir: &Path) -> PathBuf {
81 dir.join(STORE_FILE)
82}
83
84fn 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
136fn 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
169pub 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
182pub 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
207pub 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
226pub 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
238pub 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 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}