use std::path::Path;
use std::process::ExitCode;
use anyhow::Result;
use serde::Serialize;
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile::{self, DEFAULT_PROFILE, ProfileName};
use crate::repo::marker as repo_marker;
use crate::repo::registry as repo_registry;
use crate::repo::truth as project_truth;
#[derive(Serialize)]
pub struct PreflightReport {
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>,
readiness: ReadinessView,
source_order: SourceOrderView,
entrypoints: Vec<EntrypointView>,
session_sequence: Vec<SessionStepView>,
warnings: Vec<String>,
}
#[derive(Serialize)]
struct ReadinessView {
status: &'static str,
blockers: Vec<String>,
}
#[derive(Serialize)]
struct SourceOrderView {
status: &'static str,
mode: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
manifest_path: Option<String>,
manifest_status: &'static str,
entries: Vec<String>,
active_sources: Vec<String>,
note: String,
}
#[derive(Serialize)]
struct EntrypointView {
name: &'static str,
role: &'static str,
path: String,
required: bool,
status: &'static str,
note: &'static str,
}
#[derive(Serialize)]
struct SessionStepView {
command: String,
status: &'static str,
reason: String,
}
struct EntrypointPaths<'a> {
agents: &'a Path,
repo_memory: &'a Path,
readme: &'a Path,
marker: &'a Path,
profile_config: &'a Path,
profile_policy: &'a Path,
profile_memory: &'a Path,
registry: Option<&'a Path>,
registry_ready: bool,
}
impl CommandReport for PreflightReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn render_text(&self) {
println!(
"Preflight is {} for profile `{}`.",
self.readiness.status, self.profile
);
if let Some(project_id) = self.project_id.as_ref().or(self.locality_id.as_ref()) {
println!("Project ID: {project_id}");
}
println!(
"Source order: {} ({})",
self.source_order.mode, self.source_order.status
);
println!("{}", self.source_order.note);
if !self.readiness.blockers.is_empty() {
println!();
println!("Blockers:");
for blocker in &self.readiness.blockers {
println!("- {blocker}");
}
}
if !self.warnings.is_empty() {
println!();
println!("Warnings:");
for warning in &self.warnings {
println!("- {warning}");
}
}
println!();
println!("Recommended session-start sequence:");
for (index, step) in self.session_sequence.iter().enumerate() {
println!("{}. [{}] {}", index + 1, step.status, step.command);
println!(" {}", step.reason);
}
}
}
pub fn run(repo_root: &Path, explicit_profile: Option<&str>) -> Result<PreflightReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = StateLayout::resolve(repo_root, profile.clone())?;
let marker = repo_marker::load(repo_root)?;
let locality_id = marker.as_ref().map(|value| value.locality_id.clone());
let agents_path = repo_root.join("AGENTS.md");
let repo_memory_path = repo_root.join("MEMORY.md");
let readme_path = repo_root.join("README.md");
let marker_path = repo_root.join(repo_marker::MARKER_FILE);
let profile_config_path = layout.profile_config_path();
let profile_policy_path = layout.profile_policy_path();
let profile_memory_path = layout.profile_memory_path();
let profile_kernel_ready = profile_config_path.is_file()
&& profile_policy_path.is_file()
&& profile_memory_path.is_file();
let registry_path = locality_id
.as_deref()
.map(|value| layout.repo_metadata_path(value))
.transpose()?;
let registry_ready = match ®istry_path {
Some(path) => repo_registry::load(path)?.is_some(),
None => false,
};
let entrypoints = build_entrypoints(&EntrypointPaths {
agents: &agents_path,
repo_memory: &repo_memory_path,
readme: &readme_path,
marker: &marker_path,
profile_config: &profile_config_path,
profile_policy: &profile_policy_path,
profile_memory: &profile_memory_path,
registry: registry_path.as_deref(),
registry_ready,
});
let mut blockers = Vec::new();
if !agents_path.is_file() {
blockers.push(format!(
"{} is missing; seed canonical project-truth entrypoints with `ccd scaffold --path .`",
agents_path.display()
));
}
if !profile_kernel_ready {
blockers.push(format!(
"profile `{}` is not bootstrapped at {}; run `ccd attach --path .` before opening a session",
profile,
layout.profile_root().display()
));
}
if marker.is_none() {
blockers.push(format!(
"{} is missing; run `ccd attach --path .` or `ccd link --path .` before opening a session",
marker_path.display()
));
}
if marker.is_some() && !registry_ready {
let path = registry_path
.as_ref()
.expect("registry path is available when marker exists");
let linked_locality_id = locality_id
.as_deref()
.expect("project id is available when marker exists");
blockers.push(format!(
"project ID `{linked_locality_id}` is not linked in the registry at {}; run `ccd link --path . --project-id {linked_locality_id}` before opening a session",
path.display()
));
}
let mut warnings = Vec::new();
for (path, label) in [
(&repo_memory_path, "project memory"),
(&readme_path, "README"),
] {
if !path.is_file() {
warnings.push(format!(
"{} is missing at {}; the session can still start, but context will be thinner",
label,
path.display()
));
}
}
let source_order = resolve_source_order(
repo_root,
&layout,
locality_id.as_deref(),
profile_kernel_ready,
registry_ready,
)?;
if profile_kernel_ready && registry_ready {
if let Some(locality_id) = locality_id.as_deref() {
for diagnostic in
crate::extensions::health_diagnostics(&layout, repo_root, locality_id)?
{
warnings.push(diagnostic.message);
}
if let Some(message) =
project_truth::legacy_roadmap_exclusion_warning(repo_root, &layout, locality_id)?
{
warnings.push(message);
}
}
}
let session_sequence = build_session_sequence(
&profile,
explicit_profile,
!agents_path.is_file(),
!profile_kernel_ready || marker.is_none(),
marker.is_some() && !registry_ready,
locality_id.as_deref(),
);
Ok(PreflightReport {
command: "preflight",
ok: blockers.is_empty(),
path: repo_root.display().to_string(),
profile: profile.to_string(),
project_id: locality_id.clone(),
locality_id,
readiness: ReadinessView {
status: if blockers.is_empty() {
"ready"
} else {
"blocked"
},
blockers,
},
source_order,
entrypoints,
session_sequence,
warnings,
})
}
fn build_entrypoints(paths: &EntrypointPaths<'_>) -> Vec<EntrypointView> {
let mut entrypoints = vec![
file_entrypoint(
"AGENTS.md",
"project_policy",
paths.agents,
true,
"Canonical repo policy for AI agents.",
),
file_entrypoint(
"MEMORY.md",
"project_memory",
paths.repo_memory,
false,
"Optional repo-local memory surfaced during session start.",
),
file_entrypoint(
"README.md",
"project_readme",
paths.readme,
false,
"Optional repo overview surfaced by the default project-truth order.",
),
file_entrypoint(
".ccd.toml",
"repo_marker",
paths.marker,
true,
"Workspace-local marker that links this workspace to a project ID.",
),
file_entrypoint(
"config.toml",
"profile_config",
paths.profile_config,
true,
"Profile kernel config required for CCD commands.",
),
file_entrypoint(
"policy.md",
"profile_policy",
paths.profile_policy,
true,
"Profile kernel policy layer.",
),
file_entrypoint(
"memory.md",
"profile_memory",
paths.profile_memory,
true,
"Profile kernel memory layer.",
),
];
entrypoints.push(match paths.registry {
Some(path) => EntrypointView {
name: "repo.toml",
role: "repo_registry",
path: path.display().to_string(),
required: true,
status: if paths.registry_ready {
"loaded"
} else {
"missing"
},
note: "Project registry entry that confirms the project ID is linked.",
},
None => EntrypointView {
name: "repo.toml",
role: "repo_registry",
path: String::new(),
required: true,
status: "blocked",
note: "Project registry path is unknown until .ccd.toml is present.",
},
});
entrypoints
}
fn file_entrypoint(
name: &'static str,
role: &'static str,
path: &Path,
required: bool,
note: &'static str,
) -> EntrypointView {
EntrypointView {
name,
role,
path: path.display().to_string(),
required,
status: if path.is_file() { "loaded" } else { "missing" },
note,
}
}
fn resolve_source_order(
repo_root: &Path,
layout: &StateLayout,
locality_id: Option<&str>,
profile_kernel_ready: bool,
registry_ready: bool,
) -> Result<SourceOrderView> {
let Some(locality_id) = locality_id else {
return Ok(SourceOrderView {
status: "blocked",
mode: "blocked",
manifest_path: None,
manifest_status: "unknown",
entries: Vec::new(),
active_sources: Vec::new(),
note:
"Effective source order is unavailable until this workspace is linked to a project ID."
.to_owned(),
});
};
let manifest_path = layout.repo_manifest_path(locality_id)?;
if !profile_kernel_ready || !registry_ready {
return Ok(SourceOrderView {
status: "blocked",
mode: "blocked",
manifest_path: Some(manifest_path.display().to_string()),
manifest_status: "unknown",
entries: Vec::new(),
active_sources: Vec::new(),
note: "Effective source order is unavailable until the profile kernel and project registry are bootstrapped."
.to_owned(),
});
}
let resolved = project_truth::resolve_manifest(repo_root, layout, locality_id)?;
let note = match resolved.source_order {
"manifest" => "Repo manifest order is active for session startup.",
_ => "Default project-truth candidate order is active for session startup.",
};
Ok(SourceOrderView {
status: "resolved",
mode: resolved.source_order,
manifest_path: Some(resolved.manifest_path.display().to_string()),
manifest_status: resolved.manifest_status,
entries: resolved
.entries
.into_iter()
.map(|value| value.display().to_string())
.collect(),
active_sources: resolved
.project_truth_paths
.into_iter()
.map(|value| value.display().to_string())
.collect(),
note: note.to_owned(),
})
}
fn build_session_sequence(
profile: &ProfileName,
explicit_profile: Option<&str>,
needs_scaffold: bool,
needs_init: bool,
needs_link: bool,
locality_id: Option<&str>,
) -> Vec<SessionStepView> {
let mut sequence = Vec::new();
if needs_scaffold {
sequence.push(SessionStepView {
command: "ccd scaffold --path .".to_owned(),
status: "required",
reason: "Seed the canonical project-truth entrypoints before starting a session."
.to_owned(),
});
}
if needs_init {
sequence.push(SessionStepView {
command: with_profile_flag("ccd attach --path .", profile, explicit_profile),
status: "required",
reason: format!(
"Bootstrap profile `{profile}` and link this workspace before running session commands."
),
});
} else if needs_link {
sequence.push(SessionStepView {
command: with_profile_flag(
&format!(
"ccd link --path . --project-id {}",
locality_id.expect("project id is available when link is required")
),
profile,
explicit_profile,
),
status: "required",
reason: "Repair the project registry link before running session commands.".to_owned(),
});
}
let standard_status = if sequence.is_empty() {
"ready"
} else {
"pending"
};
let standard_reason = if sequence.is_empty() {
"Ready to run now."
} else {
"Run after the required prep step(s) succeed."
};
sequence.push(SessionStepView {
command: doctor_command(profile, explicit_profile),
status: standard_status,
reason: format!("{standard_reason} Verify project state before loading session context."),
});
sequence.push(SessionStepView {
command: with_profile_flag("ccd start --activate --path .", profile, explicit_profile),
status: standard_status,
reason: format!(
"{standard_reason} Load the effective session context and record workspace-local session telemetry in one call."
),
});
sequence
}
fn doctor_command(profile: &ProfileName, explicit_profile: Option<&str>) -> String {
if explicit_profile.is_none() && profile.as_str() == DEFAULT_PROFILE {
"ccd doctor --path .".to_owned()
} else {
format!("CCD_PROFILE={} ccd doctor --path .", profile.as_str())
}
}
fn with_profile_flag(
command: &str,
profile: &ProfileName,
explicit_profile: Option<&str>,
) -> String {
if explicit_profile.is_none() && profile.as_str() == DEFAULT_PROFILE {
command.to_owned()
} else {
format!("{command} --profile {}", profile.as_str())
}
}