use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};
use crate::project_root::{
find_project_root, project_slug_from_basename, read_project_pin, write_project_pin, ProjectPin,
PIN_FILE_REL, PIN_SCHEMA_VERSION,
};
pub fn handle_link(
path: Option<PathBuf>,
slug_override: Option<String>,
note: Option<String>,
force: bool,
) -> Result<()> {
let start = match path {
Some(p) => p,
None => std::env::current_dir().context("could not read current directory")?,
};
let root = find_project_root(&start).ok_or_else(|| {
anyhow::anyhow!(
"no project root found at or above '{}'. \
A project root must contain one of: .git, Cargo.toml, pyproject.toml, \
package.json, go.mod, .project-root, or .trusty-tools/.",
start.display()
)
})?;
let slug = match slug_override {
Some(ref s) => s.clone(),
None => project_slug_from_basename(&root).ok_or_else(|| {
anyhow::anyhow!(
"could not derive a palace slug from '{}'. \
Pass --slug <slug> to set one explicitly.",
root.display()
)
})?,
};
let existing = read_project_pin(&root)
.with_context(|| format!("read existing pin at {}", root.join(PIN_FILE_REL).display()))?;
let pin_path = root.join(PIN_FILE_REL);
match existing {
Some(ref existing_pin) if !force && existing_pin.palace == slug => {
println!(
"{} {} already pinned to palace '{}' (use --force to overwrite).",
"·".dimmed(),
pin_path.display().to_string().dimmed(),
slug.cyan()
);
return Ok(());
}
Some(ref existing_pin) if !force => {
println!(
"{} {} already exists with palace '{}'. \
Pass --force to overwrite with '{}'.",
"!".yellow(),
pin_path.display(),
existing_pin.palace.cyan(),
slug.cyan()
);
return Ok(());
}
_ => {} }
let pin = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: slug.clone(),
note,
};
write_project_pin(&root, &pin)
.with_context(|| format!("write pin to {}", pin_path.display()))?;
let action = if existing.is_some() {
"Updated"
} else {
"Created"
};
println!(
"{} {} {} (palace = '{}').",
"✓".green(),
action,
pin_path.display(),
slug.cyan()
);
println!(" Commit this file to lock the palace linkage across directory renames.");
Ok(())
}
pub fn resolve_link_root(path: &Path) -> Option<PathBuf> {
find_project_root(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn link_creates_pin_file() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("my-new-project");
fs::create_dir_all(root.join(".git")).unwrap();
handle_link(Some(root.clone()), None, None, false).expect("handle_link ok");
let pin = read_project_pin(&root)
.expect("read ok")
.expect("Some(pin)");
assert_eq!(pin.palace, "my-new-project");
assert_eq!(pin.schema_version, PIN_SCHEMA_VERSION);
}
#[test]
fn link_is_idempotent() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("stable-project");
fs::create_dir_all(root.join(".git")).unwrap();
handle_link(Some(root.clone()), None, None, false).expect("first call ok");
handle_link(Some(root.clone()), None, None, false).expect("second call ok");
let pin = read_project_pin(&root)
.expect("read ok")
.expect("Some(pin)");
assert_eq!(pin.palace, "stable-project");
}
#[test]
fn link_updates_slug_with_force() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("proj");
fs::create_dir_all(root.join(".git")).unwrap();
let initial = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "old-slug".to_string(),
note: None,
};
write_project_pin(&root, &initial).expect("initial write ok");
handle_link(Some(root.clone()), Some("new-slug".to_string()), None, true)
.expect("forced update ok");
let pin = read_project_pin(&root)
.expect("read ok")
.expect("Some(pin)");
assert_eq!(pin.palace, "new-slug", "slug must be updated");
}
#[test]
fn link_refuses_overwrite_without_force() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("safe-project");
fs::create_dir_all(root.join(".git")).unwrap();
let initial = ProjectPin {
schema_version: PIN_SCHEMA_VERSION,
palace: "guarded-slug".to_string(),
note: None,
};
write_project_pin(&root, &initial).expect("initial write ok");
handle_link(
Some(root.clone()),
Some("interloper-slug".to_string()),
None,
false,
)
.expect("handle_link returns Ok (non-fatal guard)");
let pin = read_project_pin(&root)
.expect("read ok")
.expect("Some(pin)");
assert_eq!(pin.palace, "guarded-slug", "slug must not change");
}
}