use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::output::CommandReport;
use crate::paths::state::default_ccd_root;
const MIGRATED_MARKER: &str = "MIGRATED.md";
const PRESENCE_DIR: &str = "presence";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MoveStatus {
Pending,
AlreadyMoved,
}
impl MoveStatus {
fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::AlreadyMoved => "already_moved",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MachineMove {
pub machine_id: String,
pub source: String,
pub destination: String,
pub status: MoveStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct PresenceMove {
pub machine_id: String,
pub source: String,
pub destination: String,
pub status: MoveStatus,
}
#[derive(Debug, Clone, Serialize)]
pub struct LegacyArtifact {
pub path: String,
pub byte_size: u64,
pub kind: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct PodMigrationSummary {
pub pod_name: String,
pub pod_dir: String,
pub action: &'static str,
pub machine_records_moved: Vec<MachineMove>,
pub presence_records_moved: Vec<PresenceMove>,
pub legacy_artifacts_preserved: Vec<LegacyArtifact>,
pub migrated_md_path: String,
}
#[derive(Debug, Serialize)]
pub struct MigrateFromPodLayoutReport {
command: &'static str,
ok: bool,
dry_run: bool,
ccd_root: String,
pods_processed: usize,
pods_already_migrated: usize,
machine_records_moved: usize,
presence_records_moved: usize,
pub pods: Vec<PodMigrationSummary>,
}
impl CommandReport for MigrateFromPodLayoutReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn render_text(&self) {
let prefix = if self.dry_run { "[dry-run] " } else { "" };
if self.pods.is_empty() {
println!("{prefix}No pod directories found under {}.", self.ccd_root);
return;
}
println!(
"{prefix}Processed {} pod(s) ({} already migrated). Relocated {} machine record(s) and {} presence record(s).",
self.pods_processed,
self.pods_already_migrated,
self.machine_records_moved,
self.presence_records_moved
);
for pod in &self.pods {
println!();
println!("pod `{}` ({})", pod.pod_name, pod.action);
for mm in &pod.machine_records_moved {
println!(
" machine `{}` [{}] -> {}",
mm.machine_id,
mm.status.as_str(),
mm.destination
);
}
for pm in &pod.presence_records_moved {
println!(
" presence `{}` [{}] -> {}",
pm.machine_id,
pm.status.as_str(),
pm.destination
);
}
if !pod.legacy_artifacts_preserved.is_empty() {
println!(
" {} legacy artifact(s) preserved in place",
pod.legacy_artifacts_preserved.len()
);
}
println!(" marker: {}", pod.migrated_md_path);
}
}
}
#[derive(Deserialize)]
struct PodManifestFile {
pod: PodSection,
}
#[derive(Deserialize)]
struct PodSection {
name: String,
}
#[derive(Deserialize)]
struct MachineManifestFile {
machine: MachineSection,
}
#[derive(Deserialize, Default)]
#[serde(default)]
struct MachineSection {
id: String,
}
pub fn from_pod_layout(
ccd_root_override: Option<&Path>,
dry_run: bool,
) -> Result<MigrateFromPodLayoutReport> {
let ccd_root = match ccd_root_override {
Some(path) => path.to_path_buf(),
None => default_ccd_root()?,
};
let pods_root = ccd_root.join("pods");
let pod_dirs = sorted_pod_dirs(&pods_root)?;
let mut pending: Vec<PendingPod> = Vec::new();
let mut already_migrated: Vec<PodMigrationSummary> = Vec::new();
for (pod_name, pod_dir) in pod_dirs {
let marker = pod_dir.join(MIGRATED_MARKER);
if marker.is_file() {
let offenders = find_pending_source_files(&pod_dir)?;
if !offenders.is_empty() {
bail!(
"{marker_path} is present but pod-scoped migration sources still exist: \
{offenders}. Remove the marker to re-migrate or move these files manually \
before re-running.",
marker_path = marker.display(),
offenders = offenders.join(", ")
);
}
already_migrated.push(PodMigrationSummary {
pod_name: pod_name.clone(),
pod_dir: pod_dir.display().to_string(),
action: "already_migrated",
machine_records_moved: Vec::new(),
presence_records_moved: Vec::new(),
legacy_artifacts_preserved: Vec::new(),
migrated_md_path: marker.display().to_string(),
});
continue;
}
pending.push(scan_pending_pod(&pod_name, &pod_dir, &ccd_root)?);
}
verify_no_duplicate_machine_ids(&pending)?;
let mut summaries: Vec<PodMigrationSummary> = Vec::new();
let mut total_machines = 0usize;
let mut total_presence = 0usize;
for plan in pending {
if !dry_run {
execute_pending_moves(&plan)?;
write_migrated_marker(&plan)?;
}
total_machines += plan
.machine_move
.as_ref()
.map(|mm| (mm.status == MoveStatus::Pending) as usize)
.unwrap_or(0);
total_presence += plan
.presence_moves
.iter()
.filter(|pm| pm.status == MoveStatus::Pending)
.count();
summaries.push(plan.into_summary());
}
let pods_already_migrated = already_migrated.len();
summaries.extend(already_migrated);
summaries.sort_by(|a, b| a.pod_name.cmp(&b.pod_name));
Ok(MigrateFromPodLayoutReport {
command: "migrate-from-pod-layout",
ok: true,
dry_run,
ccd_root: ccd_root.display().to_string(),
pods_processed: summaries.len(),
pods_already_migrated,
machine_records_moved: total_machines,
presence_records_moved: total_presence,
pods: summaries,
})
}
struct PendingPod {
pod_name: String,
pod_dir: PathBuf,
machine_move: Option<MachineMove>,
presence_moves: Vec<PresenceMove>,
legacy_artifacts: Vec<LegacyArtifact>,
marker_path: PathBuf,
ccd_root: PathBuf,
}
impl PendingPod {
fn into_summary(self) -> PodMigrationSummary {
let machine_records_moved = self.machine_move.into_iter().collect::<Vec<_>>();
PodMigrationSummary {
pod_name: self.pod_name,
pod_dir: self.pod_dir.display().to_string(),
action: "migrated",
machine_records_moved,
presence_records_moved: self.presence_moves,
legacy_artifacts_preserved: self.legacy_artifacts,
migrated_md_path: self.marker_path.display().to_string(),
}
}
}
fn sorted_pod_dirs(pods_root: &Path) -> Result<Vec<(String, PathBuf)>> {
let Ok(iter) = fs::read_dir(pods_root) else {
return Ok(Vec::new());
};
let mut entries = Vec::new();
for entry in iter {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let Some(name) = entry.file_name().to_str().map(String::from) else {
continue;
};
entries.push((name, entry.path()));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok(entries)
}
fn find_pending_source_files(pod_dir: &Path) -> Result<Vec<String>> {
let mut offenders = Vec::new();
let machine_toml = pod_dir.join("machine.toml");
if machine_toml.is_file() {
offenders.push(machine_toml.display().to_string());
}
let presence_root = pod_dir.join(PRESENCE_DIR);
if presence_root.is_dir() {
let entries: Vec<fs::DirEntry> = fs::read_dir(&presence_root)
.with_context(|| format!("failed to read {}", presence_root.display()))?
.collect::<std::io::Result<Vec<_>>>()?;
for entry in entries {
if entry.file_type()?.is_file()
&& entry
.file_name()
.to_str()
.is_some_and(|name| name.ends_with(".json"))
{
offenders.push(entry.path().display().to_string());
}
}
}
offenders.sort();
Ok(offenders)
}
fn scan_pending_pod(pod_name: &str, pod_dir: &Path, ccd_root: &Path) -> Result<PendingPod> {
let pod_toml_valid = load_pod_toml(pod_dir)?.is_some();
let machine_toml_source = pod_dir.join("machine.toml");
let machine_move = if pod_toml_valid {
if machine_toml_source.is_file() {
let id = read_machine_id(&machine_toml_source)?;
let destination = ccd_root.join("machines").join(&id).join("machine.toml");
classify_move(&machine_toml_source, &destination, &id, "machine identity")?.map(
|status| MachineMove {
machine_id: id,
source: machine_toml_source.display().to_string(),
destination: destination.display().to_string(),
status,
},
)
} else {
None
}
} else {
None
};
let mut presence_moves: Vec<PresenceMove> = Vec::new();
let presence_root = pod_dir.join(PRESENCE_DIR);
if pod_toml_valid && presence_root.is_dir() {
let mut entries: Vec<fs::DirEntry> = fs::read_dir(&presence_root)
.with_context(|| format!("failed to read {}", presence_root.display()))?
.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
if !entry.file_type()?.is_file() {
continue;
}
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
let Some(machine_id) = name.strip_suffix(".json") else {
continue;
};
let source = entry.path();
let destination = ccd_root
.join("machines")
.join(PRESENCE_DIR)
.join(format!("{machine_id}.json"));
if let Some(status) = classify_move(&source, &destination, machine_id, "presence")? {
presence_moves.push(PresenceMove {
machine_id: machine_id.to_string(),
source: source.display().to_string(),
destination: destination.display().to_string(),
status,
});
}
}
}
let moved_source_paths: Vec<PathBuf> = machine_move
.as_ref()
.filter(|mm| mm.status == MoveStatus::Pending)
.map(|mm| PathBuf::from(&mm.source))
.into_iter()
.chain(
presence_moves
.iter()
.filter(|pm| pm.status == MoveStatus::Pending)
.map(|pm| PathBuf::from(&pm.source)),
)
.collect();
let mut legacy_artifacts = Vec::new();
inventory_residual_files(pod_dir, pod_dir, &moved_source_paths, &mut legacy_artifacts)?;
legacy_artifacts.sort_by(|a, b| a.path.cmp(&b.path));
Ok(PendingPod {
pod_name: pod_name.to_string(),
pod_dir: pod_dir.to_path_buf(),
machine_move,
presence_moves,
legacy_artifacts,
marker_path: pod_dir.join(MIGRATED_MARKER),
ccd_root: ccd_root.to_path_buf(),
})
}
fn classify_move(
source: &Path,
destination: &Path,
machine_id: &str,
kind: &str,
) -> Result<Option<MoveStatus>> {
let source_present = source.is_file();
let dest_present = destination.is_file();
match (source_present, dest_present) {
(true, false) => Ok(Some(MoveStatus::Pending)),
(false, true) => Ok(Some(MoveStatus::AlreadyMoved)),
(true, true) => bail!(
"flat-layout destination already exists for machine `{machine_id}` ({kind}): \
source `{src}` and destination `{dest}` both present; resolve manually before \
re-running.",
src = source.display(),
dest = destination.display()
),
(false, false) => Ok(None),
}
}
fn load_pod_toml(pod_dir: &Path) -> Result<Option<String>> {
let path = pod_dir.join("pod.toml");
if !path.is_file() {
return Ok(None);
}
let raw =
fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?;
let manifest: PodManifestFile =
toml::from_str(&raw).with_context(|| format!("failed to parse {}", path.display()))?;
if manifest.pod.name.trim().is_empty() {
bail!("{} must declare a non-empty [pod].name", path.display());
}
Ok(Some(manifest.pod.name))
}
fn read_machine_id(manifest_path: &Path) -> Result<String> {
let raw = fs::read_to_string(manifest_path)
.with_context(|| format!("failed to read {}", manifest_path.display()))?;
let manifest: MachineManifestFile = toml::from_str(&raw)
.with_context(|| format!("failed to parse {}", manifest_path.display()))?;
if manifest.machine.id.trim().is_empty() {
bail!(
"{} must declare a non-empty machine.id",
manifest_path.display()
);
}
crate::paths::state::validate_machine_id(&manifest.machine.id)?;
Ok(manifest.machine.id)
}
fn inventory_residual_files(
root: &Path,
dir: &Path,
moved_paths: &[PathBuf],
out: &mut Vec<LegacyArtifact>,
) -> Result<()> {
let mut entries: Vec<fs::DirEntry> = fs::read_dir(dir)
.with_context(|| format!("failed to read {}", dir.display()))?
.collect::<std::io::Result<Vec<_>>>()?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
if moved_paths.iter().any(|moved| moved == &path) {
continue;
}
if path.file_name().and_then(|n| n.to_str()) == Some(MIGRATED_MARKER) {
continue;
}
let ft = entry.file_type()?;
if ft.is_dir() {
inventory_residual_files(root, &path, moved_paths, out)?;
continue;
}
if !ft.is_file() {
continue;
}
let metadata = entry.metadata()?;
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string();
out.push(LegacyArtifact {
path: relative,
byte_size: metadata.len(),
kind: classify_artifact(&path, root),
});
}
Ok(())
}
fn classify_artifact(path: &Path, root: &Path) -> &'static str {
let relative = path.strip_prefix(root).unwrap_or(path);
match relative.to_str() {
Some("policy.md") => "pod_policy",
Some("memory.md") => "pod_memory",
_ => "unknown",
}
}
fn verify_no_duplicate_machine_ids(pending: &[PendingPod]) -> Result<()> {
let mut by_id: BTreeMap<String, Vec<PathBuf>> = BTreeMap::new();
for plan in pending {
if let Some(mm) = &plan.machine_move {
by_id
.entry(mm.machine_id.clone())
.or_default()
.push(plan.pod_dir.clone());
}
}
for (id, pods) in &by_id {
if pods.len() > 1 {
let listing = pods
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
bail!("machine id `{id}` is declared in multiple pod directories: {listing}");
}
}
Ok(())
}
fn execute_pending_moves(plan: &PendingPod) -> Result<()> {
if let Some(mm) = &plan.machine_move {
if mm.status == MoveStatus::Pending {
let dest = Path::new(&mm.destination);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::rename(&mm.source, dest)
.with_context(|| format!("failed to move {} to {}", mm.source, dest.display()))?;
}
}
for pm in &plan.presence_moves {
if pm.status != MoveStatus::Pending {
continue;
}
let dest = Path::new(&pm.destination);
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::rename(&pm.source, dest)
.with_context(|| format!("failed to move {} to {}", pm.source, dest.display()))?;
}
let presence_root = plan.pod_dir.join(PRESENCE_DIR);
if presence_root.is_dir() {
if let Ok(mut entries) = fs::read_dir(&presence_root) {
if entries.next().is_none() {
let _ = fs::remove_dir(&presence_root);
}
}
}
Ok(())
}
fn write_migrated_marker(plan: &PendingPod) -> Result<()> {
let body = build_migrated_marker_body(plan);
fs::write(&plan.marker_path, body)
.with_context(|| format!("failed to write {}", plan.marker_path.display()))
}
fn build_migrated_marker_body(plan: &PendingPod) -> String {
let mut out = String::new();
out.push_str("# CCD Pod-Layer Migration\n\n");
out.push_str(&format!(
"Migrated by `ccd migrate from-pod-layout` at {} with ccd-cli {}.\n\n",
iso8601_now(),
env!("CARGO_PKG_VERSION"),
));
out.push_str(&format!(
"Source: {}\nDestination root: {}\n\n",
plan.pod_dir.display(),
plan.ccd_root.join("machines").display()
));
out.push_str("## Relocated\n\n");
if plan.machine_move.is_none() && plan.presence_moves.is_empty() {
out.push_str("- (no machine identity or presence records found)\n");
}
if let Some(mm) = &plan.machine_move {
let note = match mm.status {
MoveStatus::Pending => "",
MoveStatus::AlreadyMoved => " (already relocated by a prior run)",
};
out.push_str(&format!(
"- `machine.toml` -> `{}` (machine id `{}`){}\n",
mm.destination, mm.machine_id, note
));
}
for pm in &plan.presence_moves {
let note = match pm.status {
MoveStatus::Pending => "",
MoveStatus::AlreadyMoved => " (already relocated by a prior run)",
};
out.push_str(&format!(
"- `presence/{id}.json` -> `{dest}` (machine id `{id}`){note}\n",
id = pm.machine_id,
dest = pm.destination,
note = note
));
}
out.push_str("\n## Preserved in this directory\n\n");
out.push_str(
"These files were NOT migrated. They remain here for your review.\n\
CCD does not delete operator-authored state on the operator's behalf.\n\n",
);
if plan.legacy_artifacts.is_empty() {
out.push_str("- (none)\n");
} else {
for artifact in &plan.legacy_artifacts {
out.push_str(&format!(
"- `{path}` ({size} bytes, kind={kind})\n",
path = artifact.path,
size = artifact.byte_size,
kind = artifact.kind
));
}
}
out.push_str(
"\nTo reclaim space after reviewing, remove this directory with a\n\
standard filesystem tool (e.g. `rm -rf`). CCD provides no subcommand\n\
for the discard step by design.\n",
);
out
}
fn iso8601_now() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let days = secs / 86_400;
let days_since_epoch = days as i64;
let (year, month, day) = civil_from_days(days_since_epoch);
let hour = (secs % 86_400) / 3_600;
let minute = (secs % 3_600) / 60;
let second = secs % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let year = y + (m <= 2) as i64;
(year, m as u32, d as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_artifact_tags_known_files() {
let root = Path::new("/tmp/pod");
assert_eq!(
classify_artifact(&root.join("policy.md"), root),
"pod_policy"
);
assert_eq!(
classify_artifact(&root.join("memory.md"), root),
"pod_memory"
);
assert_eq!(classify_artifact(&root.join("pod.toml"), root), "unknown");
assert_eq!(
classify_artifact(&root.join("repos/foo/config.toml"), root),
"unknown"
);
}
#[test]
fn iso8601_now_has_expected_shape() {
let stamp = iso8601_now();
assert_eq!(stamp.len(), 20);
assert!(stamp.ends_with('Z'));
assert!(stamp.as_bytes()[4] == b'-');
assert!(stamp.as_bytes()[10] == b'T');
}
}