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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
hosts: Option<HostsConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct LegacyRawRepoMarker {
#[serde(default = "default_version")]
version: u32,
#[serde(default)]
repo_id: Option<String>,
#[serde(default)]
project_id: Option<String>,
#[serde(default)]
substrate: Option<MarkerSubstrate>,
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
hosts: Option<HostsConfig>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum MarkerRepairKind {
LegacyRepoIdV1,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct MarkerRepairPreview {
pub kind: MarkerRepairKind,
pub marker_path: String,
pub before_version: u32,
pub after_version: u32,
pub preserved_project_id: String,
pub preserved_display_name: Option<String>,
pub preserved_substrate: MarkerSubstrate,
pub preserved_hosts_applied: Option<Vec<String>>,
pub before_field: &'static str,
pub after_field: &'static str,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub(crate) struct MarkerRepairOutcome {
pub preview: MarkerRepairPreview,
pub repaired_marker: String,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct HostsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub applied: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepoMarker {
pub version: u32,
pub locality_id: String,
pub display_name: Option<String>,
pub hosts_applied: Option<Vec<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,
None,
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,
None,
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.hosts_applied.clone(),
self.substrate,
self.include_substrate,
)
}
pub fn with_hosts_applied(&self, hosts_applied: Option<Vec<String>>) -> Result<Self> {
Self::new_internal(
self.version,
self.locality_id.clone(),
self.display_name.clone(),
hosts_applied,
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)
}
fn from_legacy_v1(raw: LegacyRawRepoMarker) -> Result<Self> {
if raw.version != 1 {
bail!("legacy repair only supports version 1 markers with `repo_id`");
}
let repo_id = raw
.repo_id
.ok_or_else(|| anyhow::anyhow!("legacy version 1 marker does not contain `repo_id`"))?;
if let Some(project_id) = raw.project_id {
if project_id != repo_id {
bail!(
"legacy marker has conflicting `repo_id` and `project_id`; refusing automatic repair"
);
}
}
let substrate = raw.substrate.unwrap_or_default();
let include_substrate = raw.substrate.is_some();
Self::new_internal(
MARKER_SCHEMA_VERSION_V2,
repo_id,
raw.display_name,
raw.hosts.and_then(|h| h.applied),
substrate,
include_substrate,
)
}
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>,
hosts_applied: Option<Vec<String>>,
substrate: MarkerSubstrate,
include_substrate: bool,
) -> Result<Self> {
let marker = Self {
version,
locality_id,
display_name,
hosts_applied,
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();
let hosts_applied = raw.hosts.and_then(|h| h.applied);
Self::new_internal(
MARKER_SCHEMA_VERSION_V2,
locality_id,
raw.display_name,
hosts_applied,
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(),
hosts: self.hosts_applied.as_ref().map(|applied| HostsConfig {
applied: Some(applied.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)
}
pub(crate) fn legacy_repair_preview(repo_root: &Path) -> Result<Option<MarkerRepairPreview>> {
let path = repo_root.join(MARKER_FILE);
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()))
}
};
let raw: LegacyRawRepoMarker = match toml::from_str(&contents) {
Ok(raw) => raw,
Err(error) => {
return Err(error).context("failed to parse marker TOML for legacy repair probe")
}
};
if raw.version != 1 || raw.repo_id.is_none() {
return Ok(None);
}
let marker = RepoMarker::from_legacy_v1(raw)?;
Ok(Some(MarkerRepairPreview {
kind: MarkerRepairKind::LegacyRepoIdV1,
marker_path: path.display().to_string(),
before_version: 1,
after_version: MARKER_SCHEMA_VERSION_V2,
preserved_project_id: marker.locality_id,
preserved_display_name: marker.display_name,
preserved_substrate: marker.substrate,
preserved_hosts_applied: marker.hosts_applied,
before_field: "repo_id",
after_field: "project_id",
}))
}
pub(crate) fn repair_legacy_marker(repo_root: &Path) -> Result<Option<MarkerRepairOutcome>> {
let preview = match legacy_repair_preview(repo_root)? {
Some(preview) => preview,
None => return Ok(None),
};
let marker = RepoMarker::new_internal(
MARKER_SCHEMA_VERSION_V2,
preview.preserved_project_id.clone(),
preview.preserved_display_name.clone(),
preview.preserved_hosts_applied.clone(),
preview.preserved_substrate,
preview.preserved_substrate != MarkerSubstrate::Git,
)?;
let repaired_marker = marker.to_toml()?;
fs::write(repo_root.join(MARKER_FILE), &repaired_marker)
.with_context(|| format!("failed to write {}", repo_root.join(MARKER_FILE).display()))?;
Ok(Some(MarkerRepairOutcome {
preview,
repaired_marker,
}))
}
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"));
}
#[test]
fn round_trips_hosts_applied_list() {
let toml_input = r#"version = 2
project_id = "ccdrepo_demo"
[hosts]
applied = ["claude", "codex"]
"#;
let parsed = RepoMarker::from_toml(toml_input).expect("parse");
assert_eq!(
parsed.hosts_applied.as_deref(),
Some(&["claude".to_owned(), "codex".to_owned()][..])
);
let rendered = parsed.to_toml().expect("serialize");
let reparsed = RepoMarker::from_toml(&rendered).expect("reparse");
assert_eq!(reparsed.hosts_applied, parsed.hosts_applied);
}
#[test]
fn omits_hosts_section_when_applied_is_none() {
let marker = RepoMarker::new("ccdrepo_demo", None).expect("marker");
assert!(marker.hosts_applied.is_none());
let toml = marker.to_toml().expect("serialize");
assert!(
!toml.contains("[hosts]"),
"expected no [hosts] section, got:\n{toml}"
);
}
#[test]
fn previews_legacy_repo_id_v1_repair_without_data_loss() {
let temp = tempdir().expect("tempdir");
fs::write(
temp.path().join(MARKER_FILE),
r#"version = 1
repo_id = "ccdrepo_legacy"
substrate = "directory"
display_name = "legacy workspace"
[hosts]
applied = ["claude", "codex"]
"#,
)
.expect("write legacy marker");
let preview = legacy_repair_preview(temp.path())
.expect("repair preview succeeds")
.expect("repair is available");
assert_eq!(preview.before_version, 1);
assert_eq!(preview.after_version, 2);
assert_eq!(preview.preserved_project_id, "ccdrepo_legacy");
assert_eq!(
preview.preserved_display_name.as_deref(),
Some("legacy workspace")
);
assert_eq!(preview.preserved_substrate, MarkerSubstrate::Directory);
assert_eq!(
preview.preserved_hosts_applied.as_deref(),
Some(&["claude".to_owned(), "codex".to_owned()][..])
);
}
#[test]
fn repairs_legacy_repo_id_v1_marker_preserving_identity() {
let temp = tempdir().expect("tempdir");
fs::write(
temp.path().join(MARKER_FILE),
r#"version = 1
repo_id = "ccdrepo_legacy"
display_name = "legacy workspace"
"#,
)
.expect("write legacy marker");
let outcome = repair_legacy_marker(temp.path())
.expect("repair succeeds")
.expect("repair performed");
let repaired = fs::read_to_string(temp.path().join(MARKER_FILE)).expect("read repaired");
assert_eq!(outcome.preview.preserved_project_id, "ccdrepo_legacy");
assert!(repaired.contains("version = 2"));
assert!(repaired.contains("project_id = \"ccdrepo_legacy\""));
assert!(!repaired.contains("repo_id"));
let marker = load(temp.path())
.expect("load repaired marker")
.expect("marker");
assert_eq!(marker.locality_id, "ccdrepo_legacy");
assert_eq!(marker.display_name.as_deref(), Some("legacy workspace"));
}
}