use std::path::PathBuf;
use std::time::Duration;
use crate::execution::{ExecutionSelection, PreparedExecution, execute, preview_plan};
use crate::model::{InstallOptions, InstallOutcome, Profile};
use crate::planning::plan_profile;
use crate::reporting::write_install_report_log;
use crate::state::cache::{garbage_collect, garbage_collect_preview, status};
use crate::state::journal::{abandon as abandon_journal, load_pending, runs};
use crate::state::read_registry_document;
use crate::telemetry::summary;
use crate::ui::{self, CliOutput, Document, RawKind, StatusKind};
use crate::cli::actions::{
InstallOutputMode, parse_human_json_format, parse_install_options_with_profile, value_after,
};
fn print_human(document: Document) -> Result<(), String> {
ui::try_print(&CliOutput::Human(document))
.map_err(|error| format!("failed to write output: {error}"))
}
pub(crate) fn cmd_resume(args: &[String]) -> Result<(), String> {
let mut run_id = None;
let mut abandon = None;
let mut install_args = Vec::new();
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--run" => {
index += 1;
if run_id
.replace(value_after(args, index, "--run")?.to_string())
.is_some()
{
return Err("--run may be specified only once".to_string());
}
}
"--abandon" => {
index += 1;
if abandon
.replace(value_after(args, index, "--abandon")?.to_string())
.is_some()
{
return Err("--abandon may be specified only once".to_string());
}
}
value => install_args.push(value.to_string()),
}
index += 1;
}
if abandon.is_some() && (run_id.is_some() || !install_args.is_empty()) {
return Err(
"--abandon cannot be combined with --run, a profile, or install options".to_string(),
);
}
if let Some(run_id) = abandon {
if run_id.is_empty() || run_id.starts_with('-') {
return Err("--abandon requires a valid run id".to_string());
}
let count = abandon_journal(&run_id).map_err(|error| error.to_string())?;
if count == 0 {
return Err(format!("no pending run found: {run_id}"));
}
return print_human(
Document::with_subtitle("bot-forge", "resume")
.status(StatusKind::Success, "Abandoned unfinished transaction")
.field("Run", run_id)
.field("Transactions", count.to_string()),
);
}
let (mut options, output_mode, profile_explicit) =
parse_install_options_with_profile(&install_args)?;
if options.yes || output_mode != InstallOutputMode::Human {
return Err(
"resume does not support --yes, --format json, --format jsonl, or --quiet".to_string(),
);
}
let pending = load_pending().map_err(|error| error.to_string())?;
if pending.is_empty() {
if let Some(value) = run_id {
if value.is_empty() || value.starts_with('-') {
return Err("--run requires a valid run id".to_string());
}
return Err(format!("no pending run found: {value}"));
}
return print_human(
Document::with_subtitle("bot-forge", "resume")
.status(StatusKind::Info, "No unfinished transaction can be resumed"),
);
}
let runs = runs().map_err(|error| error.to_string())?;
let selected = match run_id {
Some(value) if !value.is_empty() && !value.starts_with('-') => value,
Some(_) => return Err("--run requires a valid run id".to_string()),
None if runs.len() == 1 => runs[0].clone(),
None => {
return Err(format!(
"multiple pending runs found ({}); use --run <id>",
runs.join(", ")
));
}
};
let checkpoints = pending
.iter()
.filter(|checkpoint| checkpoint.run_id == selected)
.collect::<Vec<_>>();
if checkpoints.is_empty() {
return Err(format!("no pending run found: {selected}"));
}
let original = checkpoints[0];
if checkpoints.iter().any(|checkpoint| {
checkpoint.profile != original.profile
|| checkpoint.plan_hash != original.plan_hash
|| checkpoint.config_hash != original.config_hash
}) {
return Err(format!(
"run {selected} has inconsistent journal profile or plan metadata; cannot resume"
));
}
if let Some(profile) = original.profile.as_deref() {
if profile_explicit && options.profile.as_str() != profile {
return Err(format!(
"run {selected} was created with profile {profile}; cannot resume with profile {}; use resume {profile} --run {selected} with the original configuration and filters",
options.profile.as_str()
));
}
options.profile = Profile::parse(profile)
.ok_or_else(|| format!("run {selected} has an invalid profile: {profile}"))?;
} else if !profile_explicit {
return Err(format!(
"run {selected} has no recorded profile; specify the original profile with resume <profile> --run {selected} and keep the original configuration and filters"
));
}
let plan = plan_profile(&options).map_err(|error| error.to_string())?;
let expected_plan_hash = &checkpoints[0].plan_hash;
let expected_config_hash = &checkpoints[0].config_hash;
if &plan.plan_hash != expected_plan_hash || &plan.config_hash != expected_config_hash {
return Err(format!(
"run {selected} configuration or plan changed for profile {}; expected plan={} config={}; restore the original configuration and filters or use --abandon {selected}",
options.profile.as_str(),
expected_plan_hash,
expected_config_hash
));
}
let components = checkpoints
.iter()
.map(|checkpoint| checkpoint.component.clone())
.collect::<Vec<_>>();
let reinstall = preview_plan(&plan)
.map_err(|error| error.to_string())?
.tools
.into_iter()
.filter(|status| status.outdated && components.contains(&status.name))
.map(|status| status.name)
.collect::<Vec<_>>();
options.yes = true;
ui::stdout_status(
StatusKind::Info,
&format!(
"Resuming {selected} · {} pending components · plan {}",
components.len(),
plan.plan_hash
),
);
let report = execute(PreparedExecution {
plan,
options,
selection: ExecutionSelection {
components,
reinstall,
},
})
.map_err(|error| error.to_string())?;
let log_path = write_install_report_log(&report).map_err(|error| error.to_string())?;
if report.outcome != InstallOutcome::Success {
return Err(format!(
"run {selected} failed to resume; log: {}",
log_path.display()
));
}
abandon_journal(&selected).map_err(|error| error.to_string())?;
print_human(
Document::with_subtitle("bot-forge", "resume")
.status(
StatusKind::Success,
"Resume complete; previous journal closed",
)
.field("Run", selected)
.labeled_path("Log", log_path.display().to_string()),
)
}
pub(crate) fn cmd_status(args: &[String]) -> Result<(), String> {
let mut json = false;
let mut profile = Profile::Standard;
let mut config_path = None;
let mut overlays = Vec::new();
let mut profile_set = false;
let mut index = 0;
while index < args.len() {
match args[index].as_str() {
"--format" => {
index += 1;
json = parse_human_json_format(value_after(args, index, "--format")?)?;
}
"--config" => {
index += 1;
config_path = Some(PathBuf::from(value_after(args, index, "--config")?));
}
"--overlay" => {
index += 1;
overlays.push(PathBuf::from(value_after(args, index, "--overlay")?));
}
value if value.starts_with('-') => {
return Err(format!("unknown status option: {value}"));
}
value if Profile::parse(value).is_some() => {
if profile_set {
return Err("status accepts only one profile".to_string());
}
profile = Profile::parse(value)
.ok_or_else(|| format!("unknown status option: {value}"))?;
profile_set = true;
}
value => return Err(format!("unknown status option: {value}")),
}
index += 1;
}
let options = InstallOptions {
profile: profile.clone(),
config_path,
overlay_paths: overlays,
..InstallOptions::default()
};
let plan = plan_profile(&options).map_err(|error| error.to_string())?;
let preview = preview_plan(&plan).map_err(|error| error.to_string())?;
let registry = read_registry_document().map_err(|error| error.to_string())?;
let entries = registry.entries;
let pending = load_pending().map_err(|error| error.to_string())?;
if json {
let text = serde_json::to_string_pretty(&serde_json::json!({
"registry_revision": registry.revision,
"profile": profile.as_str(),
"tools": preview.tools,
"managed": entries,
"pending_journals": pending,
}))
.map_err(|error| format!("failed to serialize status: {error}"))?;
ui::try_print(&CliOutput::Raw {
kind: RawKind::Json,
text: format!("{text}\n"),
})
.map_err(|error| format!("failed to write status: {error}"))?;
} else {
let mut document = Document::with_subtitle("bot-forge", "status")
.field("Profile", profile.as_str())
.field("Registry", registry.revision.to_string())
.field("Managed", entries.len().to_string())
.field("Pending", pending.len().to_string())
.blank()
.section("Components");
for status in &preview.tools {
let (kind, description) = if status.installed {
(
StatusKind::Success,
status
.version
.as_deref()
.map(|version| ui::display_tool_version(&status.name, version))
.unwrap_or_else(|| "installed".to_string()),
)
} else if status.installable {
(StatusKind::Warning, "not installed".to_string())
} else {
(StatusKind::Error, "unsupported".to_string())
};
document = document.status_item(&status.name, description, kind);
}
for status in &preview.skills {
let kind = if status.installed {
StatusKind::Success
} else if status.installable {
StatusKind::Warning
} else {
StatusKind::Error
};
document = document.status_item(
format!("skill {}", status.name),
format!(
"{} · {}",
status.agent.as_str(),
if status.installed {
"installed"
} else if status.installable {
"not installed"
} else {
"unsupported"
}
),
kind,
);
}
if !entries.is_empty() {
document = document.blank().section("Managed items");
for entry in entries {
document = document.item(
format!("{} {}", entry.kind.as_str(), entry.name),
entry.profile,
);
}
}
if !pending.is_empty() {
document = document.blank().section("Pending transactions");
for checkpoint in pending {
document = document.item(
format!("{}:{}", checkpoint.run_id, checkpoint.component),
format!("{:?} {}", checkpoint.phase, checkpoint.plan_hash),
);
}
}
print_human(document)?;
}
Ok(())
}
pub(crate) fn cmd_cache(args: &[String]) -> Result<(), String> {
let action = args
.first()
.map(String::as_str)
.ok_or("cache requires one of: status or gc")?;
let mut json = false;
match action {
"status" => {
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--format" => {
index += 1;
json = parse_human_json_format(value_after(args, index, "--format")?)?;
}
_ => return Err("unknown cache status option".to_string()),
}
index += 1;
}
let status = status().map_err(|error| error.to_string())?;
let telemetry = summary();
if json {
let mut value = serde_json::to_value(&status)
.map_err(|error| format!("failed to serialize cache status: {error}"))?;
let object = value
.as_object_mut()
.ok_or_else(|| "serialized cache status is not an object".to_string())?;
object.insert("telemetry_samples".into(), telemetry.samples.into());
object.insert("artifact_hits".into(), telemetry.artifact_hits.into());
object.insert("cache_misses".into(), telemetry.misses.into());
object.insert(
"cache_hit_rate_percent".into(),
telemetry.hit_rate_percent.into(),
);
object.insert(
"estimated_saved_ms".into(),
telemetry.estimated_saved_ms.into(),
);
object.insert("artifact_corruptions".into(), telemetry.corruptions.into());
object.insert("cargo_failures".into(), telemetry.failures.into());
object.insert("cargo_cancellations".into(), telemetry.cancellations.into());
let text = serde_json::to_string_pretty(&value)
.map_err(|error| format!("failed to serialize cache status: {error}"))?;
ui::try_print(&CliOutput::Raw {
kind: RawKind::Json,
text: format!("{text}\n"),
})
.map_err(|error| format!("failed to write cache status: {error}"))?;
} else {
let mut document = Document::with_subtitle("bot-forge", "cache status")
.labeled_path("Root", status.root.display().to_string())
.field("Files", status.files.to_string())
.field("Logical", format!("{} MiB", status.bytes / 1024 / 1024))
.field(
"Allocated",
format!("{} MiB", status.allocated_bytes / 1024 / 1024),
)
.field(
"Reclaimable",
format!("{} MiB", status.reclaimable_bytes / 1024 / 1024),
)
.field(
"Oldest modified",
status
.oldest_modified
.map_or_else(|| "none".into(), |value| value.to_string()),
)
.field(
"Newest modified",
status
.newest_modified
.map_or_else(|| "none".into(), |value| value.to_string()),
)
.field("Cache samples", telemetry.samples.to_string())
.field(
"Artifact hit rate",
format!("{}%", telemetry.hit_rate_percent),
)
.field(
"Estimated saved",
format!("{} s", telemetry.estimated_saved_ms / 1000),
)
.field("Corruptions", telemetry.corruptions.to_string())
.field("Failures", telemetry.failures.to_string())
.field("Cancellations", telemetry.cancellations.to_string())
.field("Artifacts", status.artifact_count.to_string())
.field("Downloads", status.download_count.to_string())
.field("Quarantine", status.quarantine_count.to_string())
.field("Cargo sources", status.cargo_source_caches.to_string())
.field("Cargo shards", status.cargo_work_shards.to_string())
.field("Pending", status.pending_journal_count.to_string())
.blank()
.section("Cache classes");
for (name, class) in &status.classes {
document = document.item(
name,
format!(
"{} files · {} MiB logical · {} MiB allocated",
class.files,
class.logical_bytes / 1024 / 1024,
class.allocated_bytes / 1024 / 1024
),
);
}
print_human(document)?;
}
Ok(())
}
"gc" => {
let mut age_days = 30_u64;
let mut plan_only = false;
let mut index = 1;
while index < args.len() {
match args[index].as_str() {
"--format" => {
index += 1;
json = parse_human_json_format(value_after(args, index, "--format")?)?;
}
"--dry-run" => plan_only = true,
"--max-age-days" => {
index += 1;
age_days = value_after(args, index, "--max-age-days")?
.parse()
.map_err(|_| {
"--max-age-days must be a non-negative integer".to_string()
})?;
}
value => return Err(format!("unknown cache gc option: {value}")),
}
index += 1;
}
let max_age = Duration::from_secs(age_days.saturating_mul(86_400));
let report = if plan_only {
garbage_collect_preview(max_age)
} else {
garbage_collect(max_age)
}
.map_err(|error| error.to_string())?;
if json {
let text = serde_json::to_string_pretty(&report)
.map_err(|error| format!("failed to serialize cache GC result: {error}"))?;
ui::try_print(&CliOutput::Raw {
kind: RawKind::Json,
text: format!("{text}\n"),
})
.map_err(|error| format!("failed to write cache GC result: {error}"))?;
} else {
print_human(
Document::with_subtitle(
"bot-forge",
if plan_only {
"cache gc plan"
} else {
"cache gc"
},
)
.status(
if plan_only {
StatusKind::Info
} else {
StatusKind::Success
},
if plan_only {
"GC preview"
} else {
"GC complete"
},
)
.field("Remove", report.removed.len().to_string())
.field("Retain", report.retained.len().to_string()),
)?;
}
Ok(())
}
value => Err(format!(
"unknown cache action: {value}; expected status or gc"
)),
}
}