use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{bail, Context, Result};
use serde::Serialize;
use crate::memory::entries::{self as memory_entries, StructuredMemoryEntry};
use crate::memory::file::{
append_block_to_contents, remove_entry_blocks, render_entry_block, write_memory_file,
};
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile;
use crate::repo::marker as repo_marker;
use crate::state::pod_identity;
use crate::state::text_surface::{
extract_generic_markdown_lines, parse_numbered_item, strip_fenced_blocks,
MarkdownLineExtraction,
};
use crate::timestamps;
const MEMORY_BLOCK_OPENING_FENCE: &str = "```ccd-memory";
const GENERIC_CLOSING_FENCE: &str = "```";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum MigrationClassification {
PodWideCandidate,
ProfileSpecificCandidate,
}
#[derive(Clone, Debug)]
enum PlanItemKind {
StructuredMemory {
entry: Box<StructuredMemoryEntry>,
source_path: PathBuf,
},
NarrativeMemory {
source_path: PathBuf,
},
PolicyLine {
source_path: PathBuf,
},
}
#[derive(Clone, Debug)]
pub(crate) struct MigrationPlanItem {
pub id: String,
pub surface: &'static str,
pub kind: &'static str,
pub text: String,
pub classification: MigrationClassification,
pub reason: String,
kind_data: PlanItemKind,
}
#[derive(Clone, Debug)]
pub(crate) struct MigrationAnalysis {
pub pod_name: String,
pub pod_memory_path: PathBuf,
pub pod_policy_path: PathBuf,
pub profile_memory_path: PathBuf,
pub profile_policy_path: PathBuf,
pub memory_items: Vec<MigrationPlanItem>,
pub policy_items: Vec<MigrationPlanItem>,
}
#[derive(Clone, Debug, Serialize)]
pub struct MigrationItemView {
id: String,
surface: &'static str,
kind: &'static str,
classification: MigrationClassification,
reason: String,
text: String,
}
#[derive(Clone, Debug, Serialize)]
struct MigrationWriteResult {
pod_memory_path: String,
pod_policy_path: String,
profile_memory_path: String,
profile_policy_path: String,
moved_memory_ids: Vec<String>,
moved_policy_ids: Vec<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct PodMigrateDefaultsReport {
command: &'static str,
ok: bool,
path: String,
profile: String,
pod: String,
mode: &'static str,
pod_memory_path: String,
pod_policy_path: String,
profile_memory_path: String,
profile_policy_path: String,
memory_items: Vec<MigrationItemView>,
policy_items: Vec<MigrationItemView>,
suggested_memory_ids: Vec<String>,
suggested_policy_ids: Vec<String>,
selected_memory_ids: Vec<String>,
selected_policy_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
write_result: Option<MigrationWriteResult>,
warnings: Vec<String>,
}
impl CommandReport for PodMigrateDefaultsReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
fn render_text(&self) {
println!(
"Pod-default migration {} for profile {} in pod {}.",
self.mode, self.profile, self.pod
);
println!("Profile memory: {}", self.profile_memory_path);
println!("Profile policy: {}", self.profile_policy_path);
println!("Pod memory: {}", self.pod_memory_path);
println!("Pod policy: {}", self.pod_policy_path);
println!(
"Suggestions: {} memory item(s), {} policy item(s).",
self.suggested_memory_ids.len(),
self.suggested_policy_ids.len()
);
if let Some(write_result) = &self.write_result {
println!(
"Moved {} memory item(s) and {} policy item(s).",
write_result.moved_memory_ids.len(),
write_result.moved_policy_ids.len()
);
}
for warning in &self.warnings {
println!("Warning: {warning}");
}
}
}
pub fn analyze(layout: &StateLayout, _locality_id: &str) -> Result<MigrationAnalysis> {
let identity = pod_identity::resolve_active_identity(layout)?.ok_or_else(|| {
anyhow::anyhow!(
"no active pod identity is declared for profile `{}`; declare `~/.ccd/pods/<pod>/pod.toml` first",
layout.profile()
)
})?;
let pod_name = identity.name.clone();
let pod_memory_path = identity.memory_path.clone();
let pod_policy_path = identity.policy_path.clone();
let profile_memory_path = layout.profile_memory_path();
let profile_policy_path = layout.profile_policy_path();
let profile_memory_contents = read_optional_text(&profile_memory_path)?;
let profile_policy_contents = read_optional_text(&profile_policy_path)?;
let profile_tokens = identity
.owned_profiles
.iter()
.filter(|name| name.as_str() != "main")
.cloned()
.chain(
(layout.profile().as_str() != "main").then_some(layout.profile().as_str().to_owned()),
)
.collect::<Vec<_>>();
let mut memory_items = Vec::new();
let structured = memory_entries::parse_document(&profile_memory_contents);
for entry in structured.entries {
memory_items.push(MigrationPlanItem {
id: format!("memory:{}", entry.id),
surface: "profile_memory",
kind: "structured_memory",
classification: classify_text(&entry.content, &profile_tokens),
reason: classification_reason(&entry.content, &profile_tokens),
text: entry.content.clone(),
kind_data: PlanItemKind::StructuredMemory {
entry: Box::new(entry),
source_path: profile_memory_path.clone(),
},
});
}
for (index, line) in narrative_memory_lines(&profile_memory_contents)
.into_iter()
.enumerate()
{
memory_items.push(MigrationPlanItem {
id: format!("memory:narrative:{}", index + 1),
surface: "profile_memory",
kind: "narrative_memory",
classification: classify_text(&line, &profile_tokens),
reason: classification_reason(&line, &profile_tokens),
text: line,
kind_data: PlanItemKind::NarrativeMemory {
source_path: profile_memory_path.clone(),
},
});
}
let mut policy_items = Vec::new();
for (index, line) in generic_markdown_lines(&profile_policy_contents)
.into_iter()
.enumerate()
{
policy_items.push(MigrationPlanItem {
id: format!("policy:{}", index + 1),
surface: "profile_policy",
kind: "policy_line",
classification: classify_text(&line, &profile_tokens),
reason: classification_reason(&line, &profile_tokens),
text: line,
kind_data: PlanItemKind::PolicyLine {
source_path: profile_policy_path.clone(),
},
});
}
Ok(MigrationAnalysis {
pod_name,
pod_memory_path,
pod_policy_path,
profile_memory_path,
profile_policy_path,
memory_items,
policy_items,
})
}
pub fn run(
repo_root: &Path,
explicit_profile: Option<&str>,
selected_memory_ids: &[String],
selected_policy_ids: &[String],
adopt_suggested: bool,
write: bool,
) -> Result<PodMigrateDefaultsReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = StateLayout::resolve(repo_root, profile.clone())?;
let marker = repo_marker::load(repo_root)?.ok_or_else(|| {
anyhow::anyhow!(
"repo is not linked: {} is missing; run `ccd attach --path {}` or `ccd link --path {}` first",
repo_root.join(repo_marker::MARKER_FILE).display(),
repo_root.display(),
repo_root.display()
)
})?;
let analysis = analyze(&layout, &marker.locality_id)?;
let suggested_memory_ids = suggested_ids(&analysis.memory_items);
let suggested_policy_ids = suggested_ids(&analysis.policy_items);
let mut final_memory_ids = selected_memory_ids.to_vec();
let mut final_policy_ids = selected_policy_ids.to_vec();
if adopt_suggested {
final_memory_ids.extend(suggested_memory_ids.iter().cloned());
final_policy_ids.extend(suggested_policy_ids.iter().cloned());
}
dedupe(&mut final_memory_ids);
dedupe(&mut final_policy_ids);
validate_selected_ids(&analysis.memory_items, &final_memory_ids, "memory")?;
validate_selected_ids(&analysis.policy_items, &final_policy_ids, "policy")?;
if write && final_memory_ids.is_empty() && final_policy_ids.is_empty() {
bail!(
"`--write` requires explicit `--memory-item`, `--policy-item`, or `--adopt-suggested` so the migration stays operator-reviewed"
);
}
let write_result = if write {
Some(apply_migration(
&analysis,
&final_memory_ids,
&final_policy_ids,
)?)
} else {
None
};
let mut warnings = Vec::new();
if !suggested_memory_ids.is_empty() || !suggested_policy_ids.is_empty() {
warnings.push(format!(
"profile-level defaults still look pod-wide: {} memory candidate(s), {} policy candidate(s)",
suggested_memory_ids.len(),
suggested_policy_ids.len()
));
}
if !write && final_memory_ids.is_empty() && final_policy_ids.is_empty() {
warnings.push(
"preview only: choose specific items or use `--adopt-suggested` before writing the migration"
.to_owned(),
);
}
Ok(PodMigrateDefaultsReport {
command: "pod-migrate-defaults",
ok: true,
path: repo_root.display().to_string(),
profile: profile.to_string(),
pod: analysis.pod_name,
mode: if write { "write" } else { "preview" },
pod_memory_path: analysis.pod_memory_path.display().to_string(),
pod_policy_path: analysis.pod_policy_path.display().to_string(),
profile_memory_path: analysis.profile_memory_path.display().to_string(),
profile_policy_path: analysis.profile_policy_path.display().to_string(),
memory_items: analysis
.memory_items
.iter()
.map(MigrationItemView::from)
.collect(),
policy_items: analysis
.policy_items
.iter()
.map(MigrationItemView::from)
.collect(),
suggested_memory_ids,
suggested_policy_ids,
selected_memory_ids: final_memory_ids,
selected_policy_ids: final_policy_ids,
write_result,
warnings,
})
}
fn apply_migration(
analysis: &MigrationAnalysis,
selected_memory_ids: &[String],
selected_policy_ids: &[String],
) -> Result<MigrationWriteResult> {
let selected_memory = analysis
.memory_items
.iter()
.filter(|item| selected_memory_ids.contains(&item.id))
.cloned()
.collect::<Vec<_>>();
let selected_policy = analysis
.policy_items
.iter()
.filter(|item| selected_policy_ids.contains(&item.id))
.cloned()
.collect::<Vec<_>>();
let mut profile_memory_contents = read_optional_text(&analysis.profile_memory_path)?;
let mut pod_memory_contents = read_optional_text(&analysis.pod_memory_path)?;
if !selected_memory.is_empty() {
let structured_ids = selected_memory
.iter()
.filter_map(|item| match &item.kind_data {
PlanItemKind::StructuredMemory { entry, .. } => Some(entry.id.clone()),
_ => None,
})
.collect::<Vec<_>>();
if !structured_ids.is_empty() {
profile_memory_contents =
remove_entry_blocks(&profile_memory_contents, &structured_ids)?;
}
let narrative_texts = selected_memory
.iter()
.filter_map(|item| match item.kind_data {
PlanItemKind::NarrativeMemory { .. } => Some(item.text.clone()),
_ => None,
})
.collect::<Vec<_>>();
if !narrative_texts.is_empty() {
profile_memory_contents =
remove_markdown_items(&profile_memory_contents, &narrative_texts);
}
for item in &selected_memory {
pod_memory_contents = append_item_to_pod_memory(&pod_memory_contents, item)?;
}
write_memory_file(
&analysis.profile_memory_path,
&trim_excess_blank_lines(&profile_memory_contents),
)?;
write_memory_file(
&analysis.pod_memory_path,
&trim_excess_blank_lines(&pod_memory_contents),
)?;
}
let mut profile_policy_contents = read_optional_text(&analysis.profile_policy_path)?;
let mut pod_policy_contents = read_optional_text(&analysis.pod_policy_path)?;
if !selected_policy.is_empty() {
let policy_texts = selected_policy
.iter()
.map(|item| item.text.clone())
.collect::<Vec<_>>();
profile_policy_contents = remove_markdown_items(&profile_policy_contents, &policy_texts);
for item in &selected_policy {
pod_policy_contents = append_policy_item(&pod_policy_contents, item)?;
}
write_memory_file(
&analysis.profile_policy_path,
&trim_excess_blank_lines(&profile_policy_contents),
)?;
write_memory_file(
&analysis.pod_policy_path,
&trim_excess_blank_lines(&pod_policy_contents),
)?;
}
Ok(MigrationWriteResult {
pod_memory_path: analysis.pod_memory_path.display().to_string(),
pod_policy_path: analysis.pod_policy_path.display().to_string(),
profile_memory_path: analysis.profile_memory_path.display().to_string(),
profile_policy_path: analysis.profile_policy_path.display().to_string(),
moved_memory_ids: selected_memory_ids.to_vec(),
moved_policy_ids: selected_policy_ids.to_vec(),
})
}
fn append_item_to_pod_memory(contents: &str, item: &MigrationPlanItem) -> Result<String> {
let timestamp = timestamps::current_utc_rfc3339()?;
let base = if contents.trim().is_empty() {
"# Pod Memory".to_owned()
} else {
contents.to_owned()
};
let block = match &item.kind_data {
PlanItemKind::StructuredMemory { entry, source_path } => {
let mut migrated = entry.as_ref().clone();
if migrated.source_ref.is_none() {
migrated.source_ref = Some(format!("{}#{}", source_path.display(), migrated.id));
}
render_entry_block(&migrated)
}
PlanItemKind::NarrativeMemory { source_path } => format!(
"<!-- migrated from {} item {} at {} -->\n- {}",
source_path.display(),
item.id,
timestamp,
item.text
),
PlanItemKind::PolicyLine { .. } => unreachable!("policy lines do not write to pod memory"),
};
Ok(append_block_to_contents(&base, &block))
}
fn append_policy_item(contents: &str, item: &MigrationPlanItem) -> Result<String> {
let timestamp = timestamps::current_utc_rfc3339()?;
let base = if contents.trim().is_empty() {
"# Pod Policy".to_owned()
} else {
contents.to_owned()
};
let source_path = match &item.kind_data {
PlanItemKind::PolicyLine { source_path } => source_path,
_ => unreachable!("only policy lines write to pod policy"),
};
let block = format!(
"<!-- migrated from {} item {} at {} -->\n- {}",
source_path.display(),
item.id,
timestamp,
item.text
);
Ok(append_block_to_contents(&base, &block))
}
fn validate_selected_ids(
items: &[MigrationPlanItem],
selected_ids: &[String],
label: &str,
) -> Result<()> {
let known = items
.iter()
.map(|item| item.id.as_str())
.collect::<HashSet<_>>();
let unknown = selected_ids
.iter()
.filter(|id| !known.contains(id.as_str()))
.cloned()
.collect::<Vec<_>>();
if !unknown.is_empty() {
bail!("unknown {label} migration item(s): {}", unknown.join(", "));
}
Ok(())
}
fn suggested_ids(items: &[MigrationPlanItem]) -> Vec<String> {
items
.iter()
.filter(|item| item.classification == MigrationClassification::PodWideCandidate)
.map(|item| item.id.clone())
.collect()
}
fn dedupe(items: &mut Vec<String>) {
let mut seen = HashSet::new();
items.retain(|item| seen.insert(item.clone()));
}
fn classify_text(text: &str, profile_tokens: &[String]) -> MigrationClassification {
let lowercase = text.to_ascii_lowercase();
let tool_tokens = [
"claude", "codex", "gemini", "cursor", "copilot", "opencode", "chatgpt",
];
if profile_tokens
.iter()
.map(|token| token.to_ascii_lowercase())
.any(|token| lowercase.contains(&token))
|| tool_tokens.iter().any(|token| lowercase.contains(token))
{
MigrationClassification::ProfileSpecificCandidate
} else {
MigrationClassification::PodWideCandidate
}
}
fn classification_reason(text: &str, profile_tokens: &[String]) -> String {
match classify_text(text, profile_tokens) {
MigrationClassification::PodWideCandidate => {
"does not mention any profile- or tool-specific token".to_owned()
}
MigrationClassification::ProfileSpecificCandidate => {
"mentions a profile or tool token, so it may still belong at profile scope".to_owned()
}
}
}
fn read_optional_text(path: &Path) -> Result<String> {
match fs::read_to_string(path) {
Ok(contents) => Ok(contents),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(error).with_context(|| format!("failed to read {}", path.display())),
}
}
fn narrative_memory_lines(contents: &str) -> Vec<String> {
extract_generic_markdown_lines(
&strip_fenced_blocks(contents, MEMORY_BLOCK_OPENING_FENCE, GENERIC_CLOSING_FENCE),
MarkdownLineExtraction {
inactive_headings: &[
"archived",
"archive",
"historical",
"history",
"superseded",
"stale",
],
inactive_item_prefixes: &["archived:", "stale:", "superseded:"],
inline_skip_keywords: &["archived:", "stale:"],
},
)
}
fn generic_markdown_lines(contents: &str) -> Vec<String> {
extract_generic_markdown_lines(
contents,
MarkdownLineExtraction {
inactive_headings: &[
"archived",
"archive",
"historical",
"history",
"superseded",
"stale",
],
inactive_item_prefixes: &["archived:", "stale:", "superseded:"],
inline_skip_keywords: &["archived:", "stale:"],
},
)
}
fn remove_markdown_items(contents: &str, selected_texts: &[String]) -> String {
if selected_texts.is_empty() {
return contents.to_owned();
}
let mut selected =
selected_texts
.iter()
.fold(HashMap::<String, usize>::new(), |mut counts, item| {
*counts.entry(item.trim().to_owned()).or_insert(0) += 1;
counts
});
let retained = contents
.lines()
.filter(|raw_line| {
let trimmed = raw_line.trim();
if trimmed.is_empty() {
return true;
}
let normalized = if let Some(item) = trimmed.strip_prefix("- ") {
item.trim().to_owned()
} else if let Some(item) = parse_numbered_item(trimmed) {
item
} else {
trimmed.to_owned()
};
match selected.get_mut(&normalized) {
Some(count) if *count > 0 => {
*count -= 1;
false
}
_ => true,
}
})
.collect::<Vec<_>>()
.join("\n");
trim_excess_blank_lines(&retained)
}
fn trim_excess_blank_lines(contents: &str) -> String {
let mut result = String::with_capacity(contents.len());
let mut previous_blank = false;
for line in contents.lines() {
let blank = line.trim().is_empty();
if blank && previous_blank {
continue;
}
previous_blank = blank;
result.push_str(line);
result.push('\n');
}
result.trim_end().to_owned() + "\n"
}
impl From<&MigrationPlanItem> for MigrationItemView {
fn from(value: &MigrationPlanItem) -> Self {
Self {
id: value.id.clone(),
surface: value.surface,
kind: value.kind,
classification: value.classification,
reason: value.reason.clone(),
text: value.text.clone(),
}
}
}