use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use frost_exec::ShellEnv;
use shigoto_budget::{BudgetSpec, BudgetTree};
use shigoto_dag::Dag;
use shigoto_emit::{NullEmitter, TransitionEmitter};
use shigoto_retry::RetryPolicy;
use shigoto_scheduler::{InProcessScheduler, Scheduler};
use shigoto_types::{
Job, JobId, JobKindId, JobPhase, JobScope, JobSubject, OutputSink, RecordingJob,
};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct BootStepCounters {
aliases: usize,
hooks: usize,
binds: usize,
}
const BOOT_STEP_KIND: &str = "frost.boot-step";
#[derive(Clone)]
struct BootStepJob {
name: &'static str,
lisp_source: String,
}
impl std::fmt::Debug for BootStepJob {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BootStepJob")
.field("name", &self.name)
.field("lisp_source_bytes", &self.lisp_source.len())
.finish()
}
}
#[derive(Debug, thiserror::Error)]
enum BootStepError {
#[error("frost_lisp::apply_source failed for {file}: {source}")]
Apply {
file: &'static str,
source: frost_lisp::LispError,
},
#[error("spawn_blocking join error for {file}: {source}")]
Join {
file: &'static str,
source: tokio::task::JoinError,
},
}
#[async_trait]
impl RecordingJob for BootStepJob {
type Output = BootStepCounters;
type Error = BootStepError;
const KIND: &'static str = BOOT_STEP_KIND;
fn scope(&self) -> JobScope {
JobScope::Workspace("frostmourne".to_string())
}
fn subject(&self) -> JobSubject {
JobSubject::Pinned(self.name.to_string())
}
fn output_sink(&self) -> Option<&Arc<dyn OutputSink<Self::Output>>> {
None
}
async fn execute_body(&self) -> Result<BootStepCounters, BootStepError> {
let file = self.name;
let source = self.lisp_source.clone();
let summary = tokio::task::spawn_blocking(move || {
let mut env = ShellEnv::new();
frost_lisp::apply_source(&source, &mut env)
})
.await
.map_err(|join| BootStepError::Join { file, source: join })?
.map_err(|err| BootStepError::Apply { file, source: err })?;
Ok(BootStepCounters {
aliases: summary.aliases,
hooks: summary.hooks,
binds: summary.binds,
})
}
}
fn frostmourne_lisp_dir() -> PathBuf {
let manifest_dir = option_env!("CARGO_MANIFEST_DIR")
.expect("CARGO_MANIFEST_DIR is always set by cargo at compile time");
Path::new(manifest_dir)
.join("..") .join("..") .join("..") .join("frostmourne")
.join("lisp")
}
fn discover_rc_files(dir: &Path) -> std::io::Result<Vec<(&'static str, PathBuf)>> {
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "lisp"))
.collect();
entries.sort();
Ok(entries
.into_iter()
.map(|p| {
let leaked: &'static str = Box::leak(
p.file_name()
.expect("path came from read_dir")
.to_string_lossy()
.into_owned()
.into_boxed_str(),
);
(leaked, p)
})
.collect())
}
async fn tick_to_steady_state(
scheduler: &InProcessScheduler,
dag: &mut Dag,
file_count: usize,
) -> Result<(), shigoto_scheduler::SchedulerError> {
let max_ticks = (file_count * 8).max(16);
for _ in 0..max_ticks {
let receipt = scheduler.tick(dag).await?;
if receipt.transitions_this_tick.is_empty() {
return Ok(());
}
}
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires sibling frostmourne checkout; run with --ignored"]
async fn all_frostmourne_rc_files_apply_within_timeout() {
let lisp_dir = frostmourne_lisp_dir();
assert!(
lisp_dir.is_dir(),
"frostmourne lisp dir not found at {} — ensure the frostmourne \
checkout is adjacent to frost (~/code/github/pleme-io/frostmourne/)",
lisp_dir.display()
);
let files = discover_rc_files(&lisp_dir).expect("read_dir failed on frostmourne/lisp/");
assert!(
!files.is_empty(),
"no *.lisp files found under {} — the harness has nothing to verify",
lisp_dir.display()
);
eprintln!(
" discovered {} rc files under {}",
files.len(),
lisp_dir.display()
);
let emitter: Arc<dyn TransitionEmitter> = Arc::new(NullEmitter::new());
let scheduler = InProcessScheduler::new("frostmourne-boot").with_emitter(emitter);
let mut budget = BudgetTree::new();
budget.global = Some(BudgetSpec::max_concurrent(1));
scheduler.install_budget(budget).await;
scheduler
.register_retry_policy(JobKindId::new(BOOT_STEP_KIND), RetryPolicy::NoRetry)
.await;
let mut dag = Dag::new();
let mut ids: Vec<(&'static str, JobId)> = Vec::with_capacity(files.len());
for (name, path) in &files {
let source = std::fs::read_to_string(path)
.unwrap_or_else(|e| panic!("read_to_string {}: {e}", path.display()));
let job = Arc::new(BootStepJob {
name,
lisp_source: source,
});
let id = <BootStepJob as Job>::id(&job);
scheduler.register_job(job).await;
scheduler
.set_timeout(id.clone(), Duration::from_secs(1))
.await;
dag.ensure_node(id.clone());
ids.push((name, id));
}
eprintln!(" running InProcessScheduler tick loop (budget=1, per-job timeout=1000ms)");
tick_to_steady_state(&scheduler, &mut dag, files.len())
.await
.expect("scheduler tick failed");
let snap = scheduler.snapshot(&dag).await;
let mut offenders: Vec<(&'static str, JobPhase)> = Vec::new();
for (name, id) in &ids {
match snap.phases.get(id) {
Some(JobPhase::Succeeded) => continue,
Some(other) => offenders.push((name, other.clone())),
None => offenders.push((name, JobPhase::Pending)),
}
}
assert!(
offenders.is_empty(),
"failed: {} of {} BootStepJob instances did not reach Succeeded:\n{}",
offenders.len(),
ids.len(),
offenders
.iter()
.map(|(name, phase)| format!(" - {name:<32} → {phase:?}"))
.collect::<Vec<_>>()
.join("\n")
);
eprintln!(
" all {} BootStepJob instances reached JobPhase::Succeeded",
ids.len()
);
}