use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
const STORE_FILE: &str = "project_links.json";
const SCHEMA_VERSION: u32 = 1;
pub(crate) const MAX_LINKS: usize = 1024;
const MAX_STORE_BYTES: u64 = 1024 * 1024;
pub const MAX_LABEL_CHARS: usize = 128;
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
pub const CHANGE_WITH: &str = "lean-ctx project link --project <id>";
pub const REMOVE_WITH: &str = "lean-ctx project unlink";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectLink {
pub workspace_root: String,
pub team_project_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub organization_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub linked_at: String,
}
impl ProjectLink {
pub(crate) fn mapping_json(&self) -> serde_json::Value {
let mut mapping = serde_json::Map::new();
mapping.insert("state".into(), "linked".into());
mapping.insert(
"team_project_id".into(),
self.team_project_id.clone().into(),
);
if let Some(organization) = &self.organization_id {
mapping.insert("organization_id".into(), organization.clone().into());
}
if let Some(label) = &self.label {
mapping.insert("label".into(), label.clone().into());
}
mapping.insert("linked_at".into(), self.linked_at.clone().into());
mapping.insert("change_with".into(), CHANGE_WITH.into());
mapping.insert("remove_with".into(), REMOVE_WITH.into());
serde_json::Value::Object(mapping)
}
}
pub fn mapping_json(root: &str) -> serde_json::Value {
link_for_root(root).map_or_else(|| "unmapped".into(), |link| link.mapping_json())
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct Store {
schema_version: u32,
links: BTreeMap<String, ProjectLink>,
}
fn store_path(dir: &Path) -> PathBuf {
dir.join(STORE_FILE)
}
fn partitions(root: &str) -> Vec<String> {
let mut keys = vec![crate::core::project_hash::hash_project_root(root)];
let canonical = crate::core::pathutil::safe_canonicalize_or_self(Path::new(root))
.to_string_lossy()
.into_owned();
let other = crate::core::project_hash::hash_project_root(&canonical);
if !keys.contains(&other) {
keys.push(other);
}
keys
}
fn read_store(dir: &Path) -> Result<Store, String> {
use std::io::Read as _;
let path = store_path(dir);
let file = match std::fs::File::open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Store::default()),
Err(error) => return Err(format!("{}: {error}", path.display())),
};
let mut text = String::new();
file.take(MAX_STORE_BYTES + 1)
.read_to_string(&mut text)
.map_err(|error| format!("{}: {error}", path.display()))?;
if text.len() as u64 > MAX_STORE_BYTES {
return Err(format!("{} exceeds its size bound", path.display()));
}
let store: Store = serde_json::from_str(&text)
.map_err(|error| format!("{} is not a link store: {error}", path.display()))?;
if store.schema_version != SCHEMA_VERSION || store.links.len() > MAX_LINKS {
return Err(format!("{} is not a supported link store", path.display()));
}
Ok(store)
}
fn write_store(dir: &Path, store: &Store) -> Result<(), String> {
std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
let bytes = serde_json::to_vec_pretty(store).map_err(|error| error.to_string())?;
#[cfg(unix)]
let permissions = {
use std::os::unix::fs::PermissionsExt as _;
Some(std::fs::Permissions::from_mode(0o600))
};
#[cfg(not(unix))]
let permissions = None;
crate::core::atomic_fs::try_atomic_write(&store_path(dir), &bytes, permissions.as_ref())
.map_err(|error| format!("could not write the link store: {error}"))
}
fn update<T>(
dir: &Path,
change: impl FnOnce(&mut Store) -> Result<T, String>,
) -> Result<T, String> {
use fs2::FileExt as _;
std::fs::create_dir_all(dir).map_err(|error| error.to_string())?;
let lock = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(dir.join(format!("{STORE_FILE}.lock")))
.map_err(|error| format!("could not lock the link store: {error}"))?;
let deadline = std::time::Instant::now() + LOCK_WAIT;
while lock.try_lock_exclusive().is_err() {
if std::time::Instant::now() >= deadline {
return Err("the link store is busy; try again".to_owned());
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
let mut store = read_store(dir)?;
store.schema_version = SCHEMA_VERSION;
let answer = change(&mut store)?;
write_store(dir, &store)?;
drop(lock);
Ok(answer)
}
fn data_dir() -> Result<PathBuf, String> {
crate::core::data_dir::lean_ctx_data_dir()
}
pub fn link_for_root(root: &str) -> Option<ProjectLink> {
link_in(&data_dir().ok()?, root)
}
fn link_in(dir: &Path, root: &str) -> Option<ProjectLink> {
let store = read_store(dir).ok()?;
partitions(root)
.iter()
.find_map(|key| store.links.get(key).cloned())
}
pub fn linked_checkouts(root: &str) -> Option<(ProjectLink, Vec<String>)> {
linked_checkouts_in(&data_dir().ok()?, root)
}
fn linked_checkouts_in(dir: &Path, root: &str) -> Option<(ProjectLink, Vec<String>)> {
let store = read_store(dir).ok()?;
let own = partitions(root);
let link = own.iter().find_map(|key| store.links.get(key)).cloned()?;
let others = store
.links
.iter()
.filter(|(key, other)| {
!own.contains(key)
&& other.team_project_id == link.team_project_id
&& other.organization_id == link.organization_id
})
.map(|(_, other)| other.workspace_root.clone())
.collect();
Some((link, others))
}
pub fn link(link: ProjectLink) -> Result<Option<ProjectLink>, String> {
link_into(&data_dir()?, link)
}
fn link_into(dir: &Path, link: ProjectLink) -> Result<Option<ProjectLink>, String> {
let keys = partitions(&link.workspace_root);
update(dir, |store| {
let previous = keys.iter().find_map(|key| store.links.remove(key));
if previous.is_none() && store.links.len() >= MAX_LINKS {
return Err(format!(
"{MAX_LINKS} checkouts are linked already; unlink one first"
));
}
store.links.insert(keys[0].clone(), link);
Ok(previous)
})
}
pub fn unlink(root: &str) -> Result<Option<ProjectLink>, String> {
unlink_in(&data_dir()?, root)
}
fn unlink_in(dir: &Path, root: &str) -> Result<Option<ProjectLink>, String> {
let keys = partitions(root);
update(dir, |store| {
Ok(keys.iter().find_map(|key| store.links.remove(key)))
})
}
pub fn link_time() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample(root: &str, project: &str) -> ProjectLink {
ProjectLink {
workspace_root: root.to_owned(),
team_project_id: project.to_owned(),
organization_id: None,
label: None,
linked_at: "2026-10-19T10:00:00Z".to_owned(),
}
}
#[test]
fn link_relink_unlink_by_partition() {
let store = tempfile::tempdir().unwrap();
let a = tempfile::tempdir().unwrap();
let b = tempfile::tempdir().unwrap();
let a_root = a.path().to_str().unwrap();
let b_root = b.path().to_str().unwrap();
assert!(link_in(store.path(), a_root).is_none());
assert_eq!(link_into(store.path(), sample(a_root, "gateway")), Ok(None));
assert!(link_in(store.path(), b_root).is_none());
assert_eq!(
link_in(store.path(), a_root).unwrap().team_project_id,
"gateway"
);
let replaced = link_into(store.path(), sample(a_root, "other")).unwrap();
assert_eq!(replaced.unwrap().team_project_id, "gateway");
assert_eq!(read_store(store.path()).unwrap().links.len(), 1);
assert_eq!(
unlink_in(store.path(), a_root)
.unwrap()
.unwrap()
.team_project_id,
"other"
);
assert!(link_in(store.path(), a_root).is_none());
assert_eq!(unlink_in(store.path(), a_root), Ok(None));
}
#[test]
fn mapping_omits_unknown_fields_and_is_byte_stable() {
let mut link = sample("/w", "gateway");
let bare = link.mapping_json();
assert!(bare.get("organization_id").is_none());
assert!(bare.get("label").is_none());
link.organization_id = Some("6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f".to_owned());
link.label = Some("Gateway".to_owned());
assert_eq!(
link.mapping_json().to_string(),
serde_json::json!({
"state": "linked",
"team_project_id": "gateway",
"organization_id": "6f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f",
"label": "Gateway",
"linked_at": "2026-10-19T10:00:00Z",
"change_with": "lean-ctx project link --project <id>",
"remove_with": "lean-ctx project unlink",
})
.to_string()
);
}
#[test]
fn a_damaged_store_is_never_overwritten() {
let store = tempfile::tempdir().unwrap();
std::fs::write(store_path(store.path()), "not json").unwrap();
assert!(link_into(store.path(), sample("/w", "gateway")).is_err());
assert_eq!(
std::fs::read_to_string(store_path(store.path())).unwrap(),
"not json"
);
}
}