use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde::Serialize;
use sha2::{Digest, Sha256};
use tracing::debug;
use crate::memory::entries::StructuredMemoryEntry;
use crate::memory::file::{append_block_to_contents, render_entry_block, write_memory_file};
use crate::output::CommandReport;
use crate::paths::git as git_paths;
use crate::paths::state::StateLayout;
use crate::profile;
use crate::repo::marker as repo_marker;
use crate::state::pod_identity;
use crate::state::{compiled as compiled_state, projection_metadata};
use crate::state::{
runtime::{self as runtime_state, RuntimeTextSurfaceStatus},
session as session_state,
};
const ULID_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
static ULID_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RememberScope {
Profile,
Repo,
Branch,
Clone,
Pod,
}
impl RememberScope {
fn label(self) -> &'static str {
match self {
Self::Profile => "profile",
Self::Repo => "project",
Self::Branch => "work_stream",
Self::Clone => "workspace",
Self::Pod => "pod",
}
}
}
#[derive(Serialize)]
pub struct RememberReport {
command: &'static str,
ok: bool,
path: String,
profile: String,
#[serde(skip_serializing_if = "Option::is_none")]
project_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
locality_id: Option<String>,
scope: &'static str,
mode: &'static str,
target: MemoryTargetView,
generated_entry: StructuredMemoryEntry,
entry_block: String,
#[serde(skip_serializing_if = "Option::is_none")]
write_result: Option<WriteResultView>,
warnings: Vec<String>,
}
#[derive(Serialize)]
struct MemoryTargetView {
scope: &'static str,
path: String,
status: &'static str,
}
#[derive(Serialize)]
struct WriteResultView {
file_action: &'static str,
path: String,
compiled_state_action: &'static str,
}
impl CommandReport for RememberReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
let scope_label = self.scope.replace('_', " ");
match &self.write_result {
Some(write_result) => {
println!(
"Captured memory entry {} in {} memory.",
self.generated_entry.id, scope_label
);
println!("Target: {} ({})", self.target.path, self.target.status);
println!(
"Compiled state: {}",
write_result.compiled_state_action.replace('_', " ")
);
}
None => {
println!("Prepared memory capture preview for {scope_label} memory.");
println!("Target: {} ({})", self.target.path, self.target.status);
}
}
println!("Entry type: {}", self.generated_entry.entry_type);
println!("Origin: {}", self.generated_entry.origin);
println!(
"Last touched session: {}",
self.generated_entry.last_touched_session
);
for warning in &self.warnings {
println!("Warning: {warning}");
}
println!();
println!("Generated block:");
println!("{}", self.entry_block);
}
}
#[allow(clippy::too_many_arguments)]
pub fn run(
path: &Path,
explicit_profile: Option<&str>,
requested_scope: Option<RememberScope>,
entry_type: &str,
origin: &str,
source_ref: Option<&str>,
dry_run: bool,
content: &str,
) -> Result<RememberReport> {
if content.trim().is_empty() {
bail!("memory content cannot be empty");
}
if let Some(source_ref) = source_ref {
if source_ref.trim().is_empty() {
bail!("--source-ref cannot be empty");
}
}
let repo_root = resolve_project_root(path)?;
let profile = profile::resolve(explicit_profile)?;
let layout = StateLayout::resolve(&repo_root, profile.clone())?;
ensure_profile_exists(&layout, &repo_root)?;
let marker = repo_marker::load(&repo_root)?;
let scope = resolve_scope(requested_scope, marker.as_ref());
let target = match scope {
RememberScope::Profile => load_profile_target(&layout)?,
RememberScope::Repo => load_repo_target(&layout, &repo_root, marker.as_ref())?,
RememberScope::Branch => load_branch_target(&layout, &repo_root, marker.as_ref())?,
RememberScope::Clone => load_clone_target(&layout, &repo_root, marker.as_ref())?,
RememberScope::Pod => load_pod_target(&layout, &repo_root, marker.as_ref())?,
};
let generated_entry = StructuredMemoryEntry {
id: generate_memory_id()?,
entry_type: entry_type.to_owned(),
state: "active".to_owned(),
created_at: current_utc_rfc3339()?,
last_touched_session: current_session_start_count(&layout)?,
origin: origin.to_owned(),
superseded_at: None,
decay_class: None,
expires_at: None,
tags: Vec::new(),
source_ref: source_ref.map(ToOwned::to_owned),
supersedes: Vec::new(),
content: content.to_owned(),
};
let entry_block = render_entry_block(&generated_entry);
let write_result = if dry_run {
None
} else {
debug!(scope = %scope.label(), path = %target.path.display(), "writing memory entry");
let next_contents = append_block_to_contents(&target.contents, &entry_block);
write_memory_file(&target.path, &next_contents)?;
let compiled_state_action = if let Some(marker) = marker.as_ref() {
let compiled =
compiled_state::refresh_after_write(&repo_root, &layout, &marker.locality_id)?;
if let Err(error) = projection_metadata::record_for_compiled_store(&layout, &compiled) {
projection_metadata::warn_record_error(&layout, &error);
}
"refreshed"
} else {
"not_applicable"
};
Some(WriteResultView {
file_action: "append_block",
path: target.path.display().to_string(),
compiled_state_action,
})
};
Ok(RememberReport {
command: "remember",
ok: true,
path: repo_root.display().to_string(),
profile: profile.to_string(),
project_id: marker.as_ref().map(|marker| marker.locality_id.clone()),
locality_id: marker.as_ref().map(|marker| marker.locality_id.clone()),
scope: scope.label(),
mode: if dry_run { "dry_run" } else { "write" },
target: MemoryTargetView {
scope: scope.label(),
path: target.path.display().to_string(),
status: target.status,
},
generated_entry,
entry_block,
write_result,
warnings: Vec::new(),
})
}
struct LoadedMemoryTarget {
path: PathBuf,
status: &'static str,
contents: String,
}
fn authored_memory_target(
path: PathBuf,
source_status: RuntimeTextSurfaceStatus,
contents: String,
) -> LoadedMemoryTarget {
LoadedMemoryTarget {
status: match source_status {
RuntimeTextSurfaceStatus::Missing | RuntimeTextSurfaceStatus::LoadedNative => "missing",
RuntimeTextSurfaceStatus::Empty => "empty",
RuntimeTextSurfaceStatus::Loaded => "loaded",
},
path,
contents,
}
}
fn load_profile_target(layout: &StateLayout) -> Result<LoadedMemoryTarget> {
let surface = runtime_state::load_profile_memory_surface(layout)?;
Ok(authored_memory_target(
layout.profile_memory_path(),
surface.source.status,
surface.source.content,
))
}
fn load_repo_target(
layout: &StateLayout,
repo_root: &Path,
marker: Option<&repo_marker::RepoMarker>,
) -> Result<LoadedMemoryTarget> {
let marker = marker.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()
)
})?;
ensure_repo_registry_exists(layout, repo_root, &marker.locality_id)?;
ensure_repo_overlay_exists(layout, repo_root, &marker.locality_id)?;
let surface = runtime_state::load_locality_memory_surface(layout, &marker.locality_id)?;
Ok(authored_memory_target(
layout.locality_memory_path(&marker.locality_id)?,
surface.source.status,
surface.source.content,
))
}
fn load_clone_target(
layout: &StateLayout,
repo_root: &Path,
marker: Option<&repo_marker::RepoMarker>,
) -> Result<LoadedMemoryTarget> {
let marker = marker.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()
)
})?;
ensure_repo_registry_exists(layout, repo_root, &marker.locality_id)?;
let surface = runtime_state::load_clone_memory_surface(layout)?;
Ok(LoadedMemoryTarget {
path: surface.source.path,
status: surface.source.status.as_str(),
contents: surface.source.content,
})
}
fn load_branch_target(
layout: &StateLayout,
repo_root: &Path,
marker: Option<&repo_marker::RepoMarker>,
) -> Result<LoadedMemoryTarget> {
let marker = marker.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()
)
})?;
ensure_repo_registry_exists(layout, repo_root, &marker.locality_id)?;
if !layout.resolved_substrate().is_git() {
bail!(
"branch memory is unavailable for directory-substrate workspaces because no active work stream exists; use profile, repo, clone, or pod scope instead"
);
}
ensure_repo_overlay_exists(layout, repo_root, &marker.locality_id)?;
if runtime_state::resolve_active_branch_memory_path(repo_root, layout, &marker.locality_id)?
.is_none()
{
bail!(
"work-stream memory is only available on active non-trunk named branches; switch to a feature branch before using `ccd remember --scope work-stream`"
);
}
let surface =
runtime_state::load_branch_memory_surface(repo_root, layout, &marker.locality_id)?;
Ok(LoadedMemoryTarget {
path: surface.source.path,
status: surface.source.status.as_str(),
contents: surface.source.content,
})
}
fn load_pod_target(
layout: &StateLayout,
repo_root: &Path,
marker: Option<&repo_marker::RepoMarker>,
) -> Result<LoadedMemoryTarget> {
let marker = marker.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 pod_name = pod_identity::resolve_pod_memory_binding(layout, &marker.locality_id)?
.map(|binding| binding.name)
.ok_or_else(|| {
anyhow::anyhow!("No pod context. Run ccd pod init or ccd session open --pod first.")
})?;
let pod_path = layout.pod_memory_path(&pod_name)?;
if let Some(parent) = pod_path.parent() {
std::fs::create_dir_all(parent)?;
}
let surface = runtime_state::load_pod_memory_surface(layout, &pod_name)?;
Ok(LoadedMemoryTarget {
path: surface.source.path,
status: surface.source.status.as_str(),
contents: surface.source.content,
})
}
fn resolve_scope(
requested_scope: Option<RememberScope>,
marker: Option<&repo_marker::RepoMarker>,
) -> RememberScope {
requested_scope.unwrap_or_else(|| {
if marker.is_some() {
RememberScope::Repo
} else {
RememberScope::Profile
}
})
}
fn resolve_project_root(path: &Path) -> Result<PathBuf> {
if git_paths::is_git_work_tree(path) {
return resolve_git_repo_root(path);
}
if let Some(root) = resolve_directory_project_root(path)? {
return Ok(root);
}
bail!(
"`ccd remember` requires --path to be inside a git checkout or an attached directory workspace; run `ccd attach --path {}` first for directory projects",
path.display()
);
}
fn resolve_directory_project_root(path: &Path) -> Result<Option<PathBuf>> {
for ancestor in path.ancestors() {
let Some(marker) = repo_marker::load(ancestor)? else {
continue;
};
if marker.substrate() == repo_marker::MarkerSubstrate::Directory {
return Ok(Some(ancestor.to_path_buf()));
}
}
Ok(None)
}
fn resolve_git_repo_root(path: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(path)
.output()
.with_context(|| {
format!(
"failed to run `git rev-parse --show-toplevel` in {}",
path.display()
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let detail = stderr.trim();
if detail.is_empty() {
bail!(
"`git rev-parse --show-toplevel` failed in {}",
path.display()
);
}
bail!(
"`git rev-parse --show-toplevel` failed in {}: {detail}",
path.display()
);
}
let stdout = String::from_utf8(output.stdout)
.context("git returned a non-utf8 path while resolving repo root")?;
let repo_root = PathBuf::from(stdout.trim());
repo_root
.canonicalize()
.with_context(|| format!("failed to resolve repo root {}", repo_root.display()))
}
fn ensure_profile_exists(layout: &StateLayout, repo_root: &Path) -> Result<()> {
let profile_root = layout.profile_root();
if profile_root.is_dir() {
return Ok(());
}
bail!(
"profile `{}` does not exist at {}; bootstrap it with `ccd attach --path {}` before using `ccd remember`",
layout.profile(),
profile_root.display(),
repo_root.display()
)
}
fn ensure_repo_registry_exists(
layout: &StateLayout,
repo_root: &Path,
locality_id: &str,
) -> Result<()> {
let metadata_path = layout.repo_metadata_path(locality_id)?;
if metadata_path.is_file() {
return Ok(());
}
bail!(
"repo `{}` is linked from {} but the registry entry is missing at {}; run `ccd attach --path {}` or `ccd link --path {}` to repair the overlay before using `ccd remember`",
locality_id,
repo_root.display(),
metadata_path.display(),
repo_root.display(),
repo_root.display()
)
}
fn ensure_repo_overlay_exists(
layout: &StateLayout,
repo_root: &Path,
locality_id: &str,
) -> Result<()> {
let overlay_root = layout.repo_overlay_root(locality_id)?;
if overlay_root.is_dir() {
return Ok(());
}
bail!(
"repo overlay for `{}` is missing at {}; run `ccd attach --path {}` or `ccd link --path {}` to restore it before using `ccd remember`",
locality_id,
overlay_root.display(),
repo_root.display(),
repo_root.display()
)
}
fn current_session_start_count(layout: &StateLayout) -> Result<u64> {
Ok(session_state::load_for_layout(layout)?
.map(|state| u64::from(state.start_count))
.unwrap_or(0))
}
fn generate_memory_id() -> Result<String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before UNIX_EPOCH")?;
let timestamp_ms = now.as_millis();
if timestamp_ms > 0xFFFF_FFFF_FFFF {
bail!("current time exceeds ULID timestamp range");
}
let mut bytes = [0u8; 16];
bytes[..6].copy_from_slice(&(timestamp_ms as u64).to_be_bytes()[2..]);
let counter = ULID_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut entropy = Sha256::new();
entropy.update(now.as_nanos().to_be_bytes());
entropy.update(counter.to_be_bytes());
entropy.update(std::process::id().to_be_bytes());
let digest = entropy.finalize();
bytes[6..].copy_from_slice(&digest[..10]);
Ok(format!("mem_{}", encode_ulid(bytes)))
}
fn encode_ulid(bytes: [u8; 16]) -> String {
let mut encoded = String::with_capacity(26);
let mut buffer = 0u32;
let mut bits = 2u8;
for byte in bytes {
buffer = (buffer << 8) | u32::from(byte);
bits += 8;
while bits >= 5 {
let shift = bits - 5;
let index = ((buffer >> shift) & 0x1f) as usize;
encoded.push(ULID_ALPHABET[index] as char);
buffer &= (1u32 << shift) - 1;
bits -= 5;
}
}
encoded
}
fn current_utc_rfc3339() -> Result<String> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before UNIX_EPOCH")?;
let seconds = now.as_secs() as i64;
let days = seconds.div_euclid(86_400);
let seconds_of_day = seconds.rem_euclid(86_400);
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
Ok(format!(
"{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
))
}
fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
let z = days_since_epoch + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let day_of_era = z - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
if month <= 2 {
year += 1;
}
(year, month, day)
}
#[cfg(test)]
mod tests {
use super::{civil_from_days, encode_ulid};
#[test]
fn civil_from_days_handles_epoch_boundary() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(1), (1970, 1, 2));
}
#[test]
fn encode_ulid_preserves_expected_width() {
let encoded = encode_ulid([0u8; 16]);
assert_eq!(encoded, "00000000000000000000000000");
}
}