use std::fmt::Write as _;
use std::sync::OnceLock;
use crate::substitution::{
CrudEvent, SubstitutionGraph, SubstitutionRuleSet, SubstitutionTraceReport,
};
pub const TASK_NODE: &str = "request:task";
pub const MODIFIER_NODE: &str = "request:modifier";
pub const PROGRAM_PLAN_RULES_LINO: &str = crate::seed::PROGRAM_PLAN_RULES_LINO;
#[must_use]
pub fn rules() -> &'static SubstitutionRuleSet {
static RULES: OnceLock<SubstitutionRuleSet> = OnceLock::new();
RULES.get_or_init(|| {
SubstitutionRuleSet::from_links_notation(PROGRAM_PLAN_RULES_LINO)
.expect("embedded program-plan rules must parse")
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProgramPlan {
pub base_task: String,
pub modifiers: Vec<String>,
pub resolved_task: String,
pub graph: SubstitutionGraph,
pub report: SubstitutionTraceReport,
}
impl ProgramPlan {
#[must_use]
pub fn was_modified(&self) -> bool {
self.resolved_task != self.base_task
}
#[must_use]
pub fn links_notation(&self) -> String {
let mut out = String::new();
out.push_str("program_plan\n");
let _ = writeln!(out, " base_task {}", self.base_task);
let _ = writeln!(out, " resolved_task {}", self.resolved_task);
for modifier in &self.modifiers {
let _ = writeln!(out, " modifier {modifier}");
}
for line in self.graph.links_notation().lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
for line in self.report.links_notation().lines() {
out.push_str(" ");
out.push_str(line);
out.push('\n');
}
out.trim_end().to_owned()
}
}
#[must_use]
pub fn lower(base_task: &str, modifiers: &[String]) -> ProgramPlan {
lower_with_rules(rules(), base_task, modifiers)
}
#[must_use]
pub fn lower_with_rules(
rules: &SubstitutionRuleSet,
base_task: &str,
modifiers: &[String],
) -> ProgramPlan {
let mut graph = SubstitutionGraph::new().with_link(TASK_NODE, base_task);
for modifier in modifiers {
graph.insert_link(MODIFIER_NODE, modifier);
}
let report = graph.apply_rules(rules, CrudEvent::Manual);
let resolved_task = resolved_task_from_graph(&graph).unwrap_or_else(|| base_task.to_owned());
ProgramPlan {
base_task: base_task.to_owned(),
modifiers: modifiers.to_vec(),
resolved_task,
graph,
report,
}
}
#[must_use]
pub fn resolve_task(base_task: &str, modifiers: &[String]) -> String {
lower(base_task, modifiers).resolved_task
}
fn resolved_task_from_graph(graph: &SubstitutionGraph) -> Option<String> {
graph
.links()
.into_iter()
.find(|link| link.from == TASK_NODE)
.map(|link| link.to)
}
#[cfg(test)]
mod tests {
use super::*;
fn modifiers(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
#[test]
fn embedded_rules_parse() {
let parsed = rules();
assert_eq!(parsed.id, "program_plan_rules");
assert_eq!(parsed.rules.len(), 1);
assert_eq!(parsed.rules[0].id, "path_argument_list_files");
}
#[test]
fn path_argument_upgrades_list_files() {
let plan = lower("list_files", &modifiers(&["path_argument"]));
assert_eq!(plan.resolved_task, "list_files_arg");
assert!(plan.was_modified());
assert_eq!(plan.report.applied_count(), 1);
assert!(plan.graph.contains_link(TASK_NODE, "list_files_arg"));
assert!(!plan.graph.contains_link(TASK_NODE, "list_files"));
}
#[test]
fn no_modifier_leaves_task_unchanged() {
let plan = lower("list_files", &[]);
assert_eq!(plan.resolved_task, "list_files");
assert!(!plan.was_modified());
assert_eq!(plan.report.applied_count(), 0);
}
#[test]
fn path_argument_on_already_upgraded_task_is_idempotent() {
let plan = lower("list_files_arg", &modifiers(&["path_argument"]));
assert_eq!(plan.resolved_task, "list_files_arg");
assert!(!plan.was_modified());
}
#[test]
fn unknown_task_with_modifier_is_unchanged() {
let plan = lower("hello_world", &modifiers(&["path_argument"]));
assert_eq!(plan.resolved_task, "hello_world");
assert!(!plan.was_modified());
}
#[test]
fn pipeline_is_data_driven() {
let extra = concat!(
"substitution_rules\n",
" id \"custom_rules\"\n",
" rule \"count_instead_of_list\"\n",
" order \"1\"\n",
" event \"manual\"\n",
" when \"request:modifier -> count_only\"\n",
" replace \"request:task -> list_files\"\n",
" with \"request:task -> count_files\"",
);
let custom = SubstitutionRuleSet::from_links_notation(extra).expect("custom rules parse");
let plan = lower_with_rules(&custom, "list_files", &modifiers(&["count_only"]));
assert_eq!(plan.resolved_task, "count_files");
assert!(plan.was_modified());
}
#[test]
fn links_notation_surfaces_plan_and_trace() {
let plan = lower("list_files", &modifiers(&["path_argument"]));
let notation = plan.links_notation();
assert!(notation.contains("program_plan"));
assert!(notation.contains("base_task list_files"));
assert!(notation.contains("resolved_task list_files_arg"));
assert!(notation.contains("modifier path_argument"));
assert!(notation.contains("path_argument_list_files"));
}
}