use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
pub const MARKER_FILE: &str = ".ccd.toml";
const MARKER_SCHEMA_VERSION_V2: u32 = 2;
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MarkerSubstrate {
#[default]
Git,
Directory,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawRepoMarker {
#[serde(default = "default_version")]
version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
substrate: Option<MarkerSubstrate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoMarker {
pub version: u32,
pub locality_id: String,
pub display_name: Option<String>,
substrate: MarkerSubstrate,
include_substrate: bool,
}
impl RepoMarker {
pub fn new(locality_id: impl Into<String>, display_name: Option<String>) -> Result<Self> {
Self::new_internal(
MARKER_SCHEMA_VERSION_V2,
locality_id.into(),
display_name,
MarkerSubstrate::Git,
false,
)
}
pub(crate) fn new_directory(
locality_id: impl Into<String>,
display_name: Option<String>,
) -> Result<Self> {
Self::new_internal(
MARKER_SCHEMA_VERSION_V2,
locality_id.into(),
display_name,
MarkerSubstrate::Directory,
true,
)
}
pub(crate) fn rewrite_with_locality_id(
&self,
locality_id: impl Into<String>,
display_name: Option<String>,
) -> Result<Self> {
Self::new_internal(
self.version,
locality_id.into(),
display_name,
self.substrate,
self.include_substrate,
)
}
pub(crate) fn substrate(&self) -> MarkerSubstrate {
self.substrate
}
pub fn from_toml(contents: &str) -> Result<Self> {
let raw: RawRepoMarker = toml::from_str(contents).context("failed to parse marker TOML")?;
Self::from_raw(raw)
}
pub fn to_toml(&self) -> Result<String> {
self.validate()?;
toml::to_string(&self.to_raw()).context("failed to serialize marker TOML")
}
fn validate(&self) -> Result<()> {
if self.version != MARKER_SCHEMA_VERSION_V2 {
bail!(
"unsupported marker schema version `{}`; only version 2 is supported — re-run `ccd link --path .` to upgrade",
self.version
);
}
if self.substrate != MarkerSubstrate::Git && !self.include_substrate {
bail!("version 2 markers must serialize non-git substrates explicitly");
}
validate_locality_id(&self.locality_id)?;
if let Some(display_name) = &self.display_name {
if display_name.is_empty() {
bail!("display_name cannot be empty");
}
}
Ok(())
}
fn new_internal(
version: u32,
locality_id: String,
display_name: Option<String>,
substrate: MarkerSubstrate,
include_substrate: bool,
) -> Result<Self> {
let marker = Self {
version,
locality_id,
display_name,
substrate,
include_substrate,
};
marker.validate()?;
Ok(marker)
}
fn from_raw(raw: RawRepoMarker) -> Result<Self> {
if raw.version != MARKER_SCHEMA_VERSION_V2 {
bail!(
"version {} markers are no longer supported; re-run `ccd link --path .` to upgrade to version 2",
raw.version
);
}
let locality_id = raw
.project_id
.ok_or_else(|| anyhow::anyhow!("version 2 markers require `project_id`"))?;
let include_substrate = raw.substrate.is_some();
Self::new_internal(
MARKER_SCHEMA_VERSION_V2,
locality_id,
raw.display_name,
raw.substrate.unwrap_or_default(),
include_substrate,
)
}
fn to_raw(&self) -> RawRepoMarker {
RawRepoMarker {
version: self.version,
project_id: Some(self.locality_id.clone()),
substrate: self.include_substrate.then_some(self.substrate),
display_name: self.display_name.clone(),
}
}
}
pub fn load(repo_root: &Path) -> Result<Option<RepoMarker>> {
let path = repo_root.join(MARKER_FILE);
match fs::read_to_string(&path) {
Ok(contents) => Ok(Some(RepoMarker::from_toml(&contents)?)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
pub fn write(repo_root: &Path, marker: &RepoMarker) -> Result<PathBuf> {
let path = repo_root.join(MARKER_FILE);
let contents = marker.to_toml()?;
fs::write(&path, contents).with_context(|| format!("failed to write {}", path.display()))?;
Ok(path)
}
fn default_version() -> u32 {
MARKER_SCHEMA_VERSION_V2
}
pub(crate) fn validate_locality_id(locality_id: &str) -> Result<&str> {
if locality_id.is_empty() {
bail!("project_id cannot be empty");
}
if locality_id == "." || locality_id == ".." {
bail!("project_id cannot be `.` or `..`");
}
if locality_id.contains('/') || locality_id.contains('\\') {
bail!("project_id cannot contain path separators");
}
if locality_id.as_bytes().contains(&0) {
bail!("project_id cannot contain NUL bytes");
}
Ok(locality_id)
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
#[test]
fn parses_v2_marker_with_project_id() {
let marker = RepoMarker::from_toml(
r#"
version = 2
project_id = "ccdrepo_123"
"#,
)
.expect("marker parses");
assert_eq!(marker.version, 2);
assert_eq!(marker.locality_id, "ccdrepo_123");
assert_eq!(marker.display_name, None);
assert_eq!(
marker.to_toml().expect("marker toml"),
"version = 2\nproject_id = \"ccdrepo_123\"\n"
);
}
#[test]
fn parses_v2_marker_with_project_id_and_substrate() {
let marker = RepoMarker::from_toml(
r#"
version = 2
project_id = "ccdrepo_123"
substrate = "git"
"#,
)
.expect("marker parses");
assert_eq!(marker.version, 2);
assert_eq!(marker.locality_id, "ccdrepo_123");
assert_eq!(
marker.to_toml().expect("marker toml"),
"version = 2\nproject_id = \"ccdrepo_123\"\nsubstrate = \"git\"\n"
);
}
#[test]
fn parses_v2_marker_with_directory_substrate() {
let marker = RepoMarker::from_toml(
r#"
version = 2
project_id = "ccdrepo_123"
substrate = "directory"
"#,
)
.expect("marker parses");
assert_eq!(marker.version, 2);
assert_eq!(marker.locality_id, "ccdrepo_123");
assert_eq!(
marker.to_toml().expect("marker toml"),
"version = 2\nproject_id = \"ccdrepo_123\"\nsubstrate = \"directory\"\n"
);
}
#[test]
fn parses_marker_without_explicit_version() {
let marker = RepoMarker::from_toml(
r#"
project_id = "ccdrepo_123"
"#,
)
.expect("marker parses — defaults to v2");
assert_eq!(marker.version, 2);
assert_eq!(marker.locality_id, "ccdrepo_123");
}
#[test]
fn rejects_v1_markers() {
let error = RepoMarker::from_toml(
r#"
version = 1
project_id = "ccdrepo_123"
"#,
)
.expect_err("v1 should be rejected");
assert!(error.to_string().contains("no longer supported"));
}
#[test]
fn rejects_marker_with_no_project_id() {
let error = RepoMarker::from_toml(
r#"
version = 2
display_name = "ccd-guide"
"#,
)
.expect_err("missing project_id should fail");
assert!(error.to_string().contains("require"));
}
#[test]
fn rejects_legacy_repo_id_field() {
let error = RepoMarker::from_toml(
r#"
version = 2
repo_id = "ccdrepo_123"
"#,
)
.expect_err("repo_id field should be rejected as unknown");
let message = format!("{error:#}");
assert!(message.contains("repo_id"));
}
#[test]
fn rejects_legacy_marker_fields() {
let error = RepoMarker::from_toml(
r#"
version = 2
project_id = "ccdrepo_123"
branch_on_session = false
"#,
)
.expect_err("legacy fields should fail");
let message = format!("{error:#}");
assert!(message.contains("branch_on_session"));
}
#[test]
fn writes_and_loads_marker_file() {
let temp = tempdir().expect("tempdir");
let marker = RepoMarker::new("ccdrepo_123", Some("ccd-guide".to_owned())).expect("marker");
let path = write(temp.path(), &marker).expect("marker written");
let reloaded = load(temp.path()).expect("marker loaded");
assert_eq!(path, temp.path().join(MARKER_FILE));
assert_eq!(reloaded, Some(marker));
}
#[test]
fn writes_and_loads_v2_directory_marker_file() {
let temp = tempdir().expect("tempdir");
let marker = RepoMarker::from_toml(
r#"
version = 2
project_id = "ccdrepo_123"
substrate = "directory"
display_name = "ccd-guide"
"#,
)
.expect("marker");
let path = write(temp.path(), &marker).expect("marker written");
let reloaded = load(temp.path()).expect("marker loaded");
assert_eq!(path, temp.path().join(MARKER_FILE));
assert_eq!(reloaded, Some(marker));
}
#[test]
fn rewrite_preserves_project_id_encoding() {
let marker = RepoMarker::from_toml(
r#"
version = 2
project_id = "ccdrepo_123"
substrate = "git"
"#,
)
.expect("marker parses");
let updated = marker
.rewrite_with_locality_id("ccdrepo_456", Some("ccd-guide".to_owned()))
.expect("marker rewrites");
assert_eq!(
updated.to_toml().expect("marker toml"),
"version = 2\nproject_id = \"ccdrepo_456\"\nsubstrate = \"git\"\ndisplay_name = \"ccd-guide\"\n"
);
}
#[test]
fn rejects_invalid_project_ids() {
let error = RepoMarker::new("../bad", None).expect_err("project_id should fail");
assert!(error.to_string().contains("project_id"));
}
}