use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use super::event::{Event, PlanStatus};
use super::ids::{IdeaId, NodeId, PlanId};
use super::store::Store;
pub const DEMO_SOURCE: &str = "funnel:demo";
pub const MANIFEST_NAME: &str = ".nornir-funnel-demo.json";
struct SplitMix64(u64);
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self(seed)
}
fn next_u64(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next_u64() % (n as u64)) as usize
}
fn chance(&mut self, num: u64, den: u64) -> bool {
self.next_u64() % den < num
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemoManifest {
pub kind: String,
pub created_at: chrono::DateTime<Utc>,
pub size: usize,
pub repos: Vec<PathBuf>,
pub container: Option<PathBuf>,
}
impl DemoManifest {
fn path(git_root: &Path) -> PathBuf {
git_root.join(MANIFEST_NAME)
}
fn load(git_root: &Path) -> Option<Self> {
let p = Self::path(git_root);
let txt = std::fs::read_to_string(p).ok()?;
serde_json::from_str(&txt).ok()
}
fn save(&self, git_root: &Path) -> Result<()> {
let p = Self::path(git_root);
let txt = serde_json::to_string_pretty(self).context("serialize demo manifest")?;
std::fs::write(&p, txt).with_context(|| format!("write manifest {}", p.display()))?;
Ok(())
}
}
pub fn resolve_git_root(funnel_root: &Path) -> PathBuf {
let ws = funnel_root
.parent() .and_then(|p| p.parent()) .unwrap_or(funnel_root);
if let Some(parent) = ws.parent() {
let sib = parent.join("git");
if sib.exists() {
return sib;
}
return sib; }
ws.join("git")
}
fn repo_dir(git_root: &Path, size: usize, i: usize) -> PathBuf {
if size == 1 {
git_root.join("funnel_fake").join("funnel1_fake_project")
} else {
git_root
.join(format!("funnel{size}_fake_projects"))
.join(format!("funnel_fake_{i}"))
}
}
fn container_dir(git_root: &Path, size: usize) -> PathBuf {
if size == 1 {
git_root.join("funnel_fake")
} else {
git_root.join(format!("funnel{size}_fake_projects"))
}
}
fn crate_name(i: usize) -> String {
format!("funnel_fake_{i}")
}
#[derive(Debug, Clone)]
pub struct DemoNodeSpec {
pub node_id: NodeId,
pub kind: String,
pub targets: Vec<String>,
pub deps: Vec<NodeId>,
pub layer: usize,
}
pub fn build_dep_graph_spec(
plans: usize,
nodes_per_plan: usize,
layers: usize,
seed: u64,
) -> Vec<Vec<DemoNodeSpec>> {
let mut rng = SplitMix64::new(seed);
let layers = layers.max(1);
let mut out = Vec::with_capacity(plans);
let mut next_node = 1u64;
for _plan in 0..plans {
let n = nodes_per_plan.max(1);
let mut node_layer = Vec::with_capacity(n);
for k in 0..n {
let base = (k * layers) / n; let jitter = if layers > 1 && rng.chance(1, 4) {
(base + 1).min(layers - 1)
} else {
base
};
node_layer.push(jitter);
}
if !node_layer.iter().any(|&l| l == 0) {
node_layer[0] = 0;
}
let ids: Vec<NodeId> = (0..n)
.map(|_| {
let id = NodeId::seq(next_node);
next_node += 1;
id
})
.collect();
let mut specs = Vec::with_capacity(n);
for k in 0..n {
let layer = node_layer[k];
let lower: Vec<usize> =
(0..n).filter(|&j| node_layer[j] < layer).collect();
let mut deps = Vec::new();
if !lower.is_empty() {
let want = 1 + rng.below(2.min(lower.len())); let mut chosen = std::collections::BTreeSet::new();
for _ in 0..want {
chosen.insert(lower[rng.below(lower.len())]);
}
deps = chosen.into_iter().map(|j| ids[j].clone()).collect();
}
specs.push(DemoNodeSpec {
node_id: ids[k].clone(),
kind: "code:write".to_string(),
targets: vec![format!("{}/src/lib.rs", crate_name(k + 1))],
deps,
layer,
});
}
out.push(specs);
}
out
}
pub fn inject_dep_graph(
store: &mut Store,
plans: usize,
nodes_per_plan: usize,
layers: usize,
seed: u64,
) -> Result<Option<(IdeaId, Vec<PlanId>)>> {
if store
.funnel
.ideas
.values()
.any(|i| i.source == DEMO_SOURCE)
{
return Ok(None);
}
let base = Utc::now();
let mut tick = 0i64;
let mut at = || {
tick += 1;
base + chrono::Duration::microseconds(tick)
};
let idea_id = IdeaId::seq(store.funnel.next_idea.max(1));
store.record(Event::IdeaSubmitted {
id: idea_id.clone(),
source: DEMO_SOURCE.to_string(),
text: format!(
"funnel demo: {plans} plan(s) × {nodes_per_plan} nodes × {layers} layers (seed {seed})"
),
refs: vec![],
ts: at(),
})?;
let specs = build_dep_graph_spec(plans, nodes_per_plan, layers, seed);
let mut plan_ids = Vec::with_capacity(plans);
for (pi, plan_specs) in specs.iter().enumerate() {
let plan_id = PlanId::seq(store.funnel.next_plan.max(1));
store.record(Event::PlanCreated {
id: plan_id.clone(),
idea_id: idea_id.clone(),
summary: format!("demo plan {} (fake dep graph)", pi + 1),
planner: DEMO_SOURCE.to_string(),
ts: at(),
})?;
store.record(Event::PlanStatusChanged {
plan_id: plan_id.clone(),
status: PlanStatus::Active,
why: None,
ts: at(),
})?;
for spec in plan_specs {
store.record(Event::NodeAdded {
plan_id: plan_id.clone(),
node_id: spec.node_id.clone(),
kind: spec.kind.clone(),
params: serde_json::Map::new(),
targets: spec.targets.clone(),
prompt_excerpt: None,
ts: at(),
})?;
}
for spec in plan_specs {
for dep in &spec.deps {
store.record(Event::EdgeAdded {
plan_id: plan_id.clone(),
from_node: dep.clone(),
to_node: spec.node_id.clone(),
ts: at(),
})?;
}
}
plan_ids.push(plan_id);
}
store.funnel.promote_ready();
Ok(Some((idea_id, plan_ids)))
}
pub fn inject_demo(store: &mut Store, size: usize) -> Result<Option<(IdeaId, Vec<PlanId>)>> {
inject_dep_graph(store, 1, size.max(1) * 3, 3, 0xF1)
}
fn write_fake_repo(dir: &Path, size: usize, i: usize) -> Result<()> {
let src = dir.join("src");
std::fs::create_dir_all(&src)
.with_context(|| format!("create {}", src.display()))?;
let name = crate_name(i);
let mut deps = String::new();
for j in 1..i {
let sibling = crate_name(j);
let rel = format!("../{sibling}");
deps.push_str(&format!("{sibling} = {{ path = \"{rel}\" }}\n"));
}
let cargo = format!(
"[package]\n\
name = \"{name}\"\n\
version = \"0.1.0\"\n\
edition = \"2021\"\n\
publish = false\n\
\n\
# Generated by `nornir funnel demo` (size {size}). Safe to delete via\n\
# `nornir funnel nuke --yes`.\n\
[dependencies]\n\
{deps}"
);
std::fs::write(dir.join("Cargo.toml"), cargo)
.with_context(|| format!("write Cargo.toml in {}", dir.display()))?;
let lib = format!(
"//! Fake demo crate `{name}` — generated by `nornir funnel demo`.\n\
//! Part of a layered path-dep graph mirroring the funnel demo DAG.\n\
\n\
/// Returns this crate's 1-based index in the demo dep graph.\n\
pub fn index() -> usize {{\n {i}\n}}\n"
);
std::fs::write(src.join("lib.rs"), lib)
.with_context(|| format!("write src/lib.rs in {}", dir.display()))?;
git_init_commit(dir)?;
Ok(())
}
fn git_init_commit(dir: &Path) -> Result<()> {
use std::process::Command;
let run = |args: &[&str]| -> std::io::Result<std::process::Output> {
Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "nornir-funnel-demo")
.env("GIT_AUTHOR_EMAIL", "demo@nornir.local")
.env("GIT_COMMITTER_NAME", "nornir-funnel-demo")
.env("GIT_COMMITTER_EMAIL", "demo@nornir.local")
.output()
};
if !dir.join(".git").exists() {
let _ = run(&["init", "-q"]);
let _ = run(&["checkout", "-q", "-B", "main"]);
}
let _ = run(&["add", "-A"]);
let _ = run(&[
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"-m",
"funnel demo snapshot",
]);
Ok(())
}
pub fn run_demo(funnel_root: &Path, size: usize) -> Result<DemoManifest> {
let size = size.max(1);
let git_root = resolve_git_root(funnel_root);
std::fs::create_dir_all(&git_root)
.with_context(|| format!("create git root {}", git_root.display()))?;
let mut repos = Vec::with_capacity(size);
for i in 1..=size {
let dir = repo_dir(&git_root, size, i);
write_fake_repo(&dir, size, i)?;
repos.push(dir);
}
let container = if size == 1 {
Some(container_dir(&git_root, size))
} else {
Some(container_dir(&git_root, size))
};
let manifest = DemoManifest {
kind: "nornir-funnel-demo".to_string(),
created_at: Utc::now(),
size,
repos,
container,
};
manifest.save(&git_root)?;
let mut store = Store::open(funnel_root)
.with_context(|| format!("open funnel store at {}", funnel_root.display()))?;
inject_repo_dag(&mut store, size)?;
Ok(manifest)
}
fn inject_repo_dag(store: &mut Store, size: usize) -> Result<Option<(IdeaId, PlanId)>> {
if store.funnel.ideas.values().any(|i| i.source == DEMO_SOURCE) {
return Ok(None);
}
let base = Utc::now();
let mut tick = 0i64;
let mut at = || {
tick += 1;
base + chrono::Duration::microseconds(tick)
};
let idea_id = IdeaId::seq(store.funnel.next_idea.max(1));
store.record(Event::IdeaSubmitted {
id: idea_id.clone(),
source: DEMO_SOURCE.to_string(),
text: format!("funnel demo: {size} fake repo(s) with a layered path-dep graph"),
refs: vec![],
ts: at(),
})?;
let plan_id = PlanId::seq(store.funnel.next_plan.max(1));
store.record(Event::PlanCreated {
id: plan_id.clone(),
idea_id: idea_id.clone(),
summary: format!("demo: {size} fake repos (path-dep DAG)"),
planner: DEMO_SOURCE.to_string(),
ts: at(),
})?;
store.record(Event::PlanStatusChanged {
plan_id: plan_id.clone(),
status: PlanStatus::Active,
why: None,
ts: at(),
})?;
let mut ids = Vec::with_capacity(size);
for i in 1..=size {
let nid = NodeId::seq(store.funnel.next_node.max(1));
store.record(Event::NodeAdded {
plan_id: plan_id.clone(),
node_id: nid.clone(),
kind: "code:write".to_string(),
params: serde_json::Map::new(),
targets: vec![format!("{}/src/lib.rs", crate_name(i))],
prompt_excerpt: None,
ts: at(),
})?;
ids.push(nid);
}
for i in 1..=size {
for j in 1..i {
store.record(Event::EdgeAdded {
plan_id: plan_id.clone(),
from_node: ids[j - 1].clone(),
to_node: ids[i - 1].clone(),
ts: at(),
})?;
}
}
store.funnel.promote_ready();
Ok(Some((idea_id, plan_id)))
}
pub fn nuke_plan(funnel_root: &Path) -> Option<DemoManifest> {
let git_root = resolve_git_root(funnel_root);
DemoManifest::load(&git_root)
}
pub fn nuke_disk(funnel_root: &Path) -> Result<Vec<PathBuf>> {
let git_root = resolve_git_root(funnel_root);
let Some(manifest) = DemoManifest::load(&git_root) else {
return Ok(Vec::new());
};
let mut removed = Vec::new();
for repo in &manifest.repos {
if repo.exists() {
std::fs::remove_dir_all(repo)
.with_context(|| format!("remove fake repo {}", repo.display()))?;
removed.push(repo.clone());
}
}
if let Some(container) = &manifest.container {
if container.exists() {
let empty = std::fs::read_dir(container)
.map(|mut d| d.next().is_none())
.unwrap_or(false);
if empty {
let _ = std::fs::remove_dir(container);
}
}
}
let _ = std::fs::remove_file(DemoManifest::path(&git_root));
Ok(removed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_edges_are_layer_monotone() {
let specs = build_dep_graph_spec(2, 12, 4, 0xABCD);
for plan in &specs {
let layer_of: std::collections::HashMap<&NodeId, usize> =
plan.iter().map(|s| (&s.node_id, s.layer)).collect();
for s in plan {
for dep in &s.deps {
let dl = layer_of[dep];
assert!(dl < s.layer, "edge {dep} -> {} not layer-monotone", s.node_id);
}
}
}
}
#[test]
fn spec_is_deterministic() {
let a = build_dep_graph_spec(1, 9, 3, 7);
let b = build_dep_graph_spec(1, 9, 3, 7);
assert_eq!(a.len(), b.len());
for (pa, pb) in a.iter().zip(&b) {
assert_eq!(pa.len(), pb.len());
for (na, nb) in pa.iter().zip(pb) {
assert_eq!(na.node_id, nb.node_id);
assert_eq!(na.deps, nb.deps);
assert_eq!(na.layer, nb.layer);
}
}
}
#[test]
fn spec_has_a_root() {
let specs = build_dep_graph_spec(1, 20, 5, 1);
assert!(specs[0].iter().any(|s| s.layer == 0 && s.deps.is_empty()));
}
}