use std::collections::BTreeMap;
use std::sync::Arc;
use camino::Utf8PathBuf;
use jiff::Timestamp;
use tokio::sync::Semaphore;
use crate::ai::{AiAgent, Backend};
use crate::applied::{AppliedState, AppliedTemplate};
use crate::config::ProjectEntry;
use crate::error::{Error, Result};
use crate::manifest::{AiMode, FileSpec, VarSpec, WhenMode};
use crate::modes::{ActionContext, OutcomeKind, for_how};
use crate::preset::TemplateRef;
use crate::render::{Renderer, VarResolver, VarSources, build_context, deep_merge_table};
use crate::template::TemplateHandle;
#[derive(Debug, Clone)]
pub struct PjApplyOptions {
pub dry_run: bool,
pub no_ai: bool,
pub interactive: bool,
pub cli_vars: BTreeMap<String, toml::Value>,
pub force_once: bool,
pub yes_all: bool,
pub ai_prompt: Option<String>,
pub agent_backend: Option<Backend>,
pub ai_mode_override: Option<AiMode>,
pub ai_concurrency: usize,
pub reseed: std::collections::HashSet<String>,
}
#[derive(Debug)]
pub struct PjApplyResult {
pub project_name: String,
pub actions: Vec<(String, OutcomeKind)>,
pub errors: Vec<(String, String)>,
}
#[allow(clippy::too_many_arguments)]
pub async fn apply_to_pj(
project: ProjectEntry,
pj_root: Utf8PathBuf,
templates: Vec<TemplateRef>,
base_dir: Utf8PathBuf,
preset_vars: toml::Table,
preset_spec: Option<String>,
opts: PjApplyOptions,
agent: Option<Arc<dyn AiAgent>>,
) -> Result<PjApplyResult> {
let mut applied = AppliedState::load(&pj_root)?;
let ai_sema = Arc::new(Semaphore::new(opts.ai_concurrency.max(1)));
let mut handles: Vec<TemplateHandle> = Vec::with_capacity(templates.len());
for t in &templates {
handles.push(TemplateHandle::load(t, &base_dir).await?);
}
let mut all_specs: BTreeMap<String, VarSpec> = BTreeMap::new();
for h in &handles {
for (k, v) in &h.manifest.vars {
all_specs.insert(k.clone(), v.clone());
}
}
let env_vars = VarSources::from_env();
let vars_file = VarSources::load_vars_file(&pj_root)?;
let template_seed = collect_template_seed_vars(&handles)?;
let sources = VarSources {
cli: opts.cli_vars.clone(),
env: env_vars,
vars_file,
applied: applied.vars.clone(),
preset: preset_vars,
template_seed,
};
let resolver = VarResolver {
specs: &all_specs,
sources: &sources,
interactive: opts.interactive,
prompter: |name: &str, spec: &VarSpec| crate::interactive::prompt_var(name, spec),
};
let resolved = resolver.resolve()?;
let vars = &resolved.values;
let ctx = build_context(&project, &pj_root, vars);
let mut renderer = Renderer::new();
let mut actions = Vec::new();
let mut errors = Vec::new();
let mut applied_templates: Vec<AppliedTemplate> = Vec::new();
let mut has_any_write = false;
let mut once_applied_dsts: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut wrote_in_run: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut initial_disk_by_dst: std::collections::HashMap<String, Option<String>> =
std::collections::HashMap::new();
let mut action_indices_by_dst: std::collections::HashMap<String, Vec<usize>> =
std::collections::HashMap::new();
for handle in &handles {
applied_templates.push(AppliedTemplate {
source: handle.source_spec.clone(),
rev: handle.rev.clone(),
subdir: handle.subdir.clone(),
version: handle.manifest.version.clone(),
});
for spec in &handle.manifest.files {
check_relative_contained(&spec.src, "template src")?;
let dst_rel = render_dst(&mut renderer, spec, &ctx)?;
check_relative_contained(&dst_rel, "destination")?;
let dst_abs = pj_root.join(&dst_rel);
let src_abs = handle.root.join(&spec.src);
let state_key = dst_rel.clone();
if !initial_disk_by_dst.contains_key(&state_key) {
let initial = if dst_abs.is_file() {
read_existing_text(dst_abs.as_path())?
} else {
None
};
initial_disk_by_dst.insert(state_key.clone(), initial);
}
if spec.when == WhenMode::Once && !opts.force_once && !opts.reseed.contains(&state_key)
{
if let Some(state) = applied.files.get(&state_key) {
if state.once_applied {
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, OutcomeKind::Skipped));
continue;
}
}
if dst_abs.is_file() && !wrote_in_run.contains(&state_key) {
if !opts.dry_run {
let mut fs = applied.files.get(&state_key).cloned().unwrap_or_default();
fs.content_hash = None;
applied.record(&state_key, fs);
once_applied_dsts.insert(state_key.clone());
wrote_in_run.insert(state_key.clone());
}
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, OutcomeKind::Adopted));
continue;
}
if dst_abs.exists() && !wrote_in_run.contains(&state_key) {
let msg = format!("destination exists but is not a regular file: {dst_abs}");
errors.push((dst_rel.clone(), msg));
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, OutcomeKind::Failed));
continue;
}
}
if spec.when == WhenMode::Manual {
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, OutcomeKind::Skipped));
continue;
}
if let Some(expr) = &spec.when_expr {
if !eval_truthy(&mut renderer, expr, &ctx)? {
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, OutcomeKind::Skipped));
continue;
}
}
let raw = match std::fs::read_to_string(src_abs.as_std_path()) {
Ok(s) => s,
Err(e) => {
errors.push((dst_rel.clone(), format!("read source: {e}")));
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, OutcomeKind::Failed));
continue;
}
};
let rendered_body = render_or_passthrough(spec, raw, &ctx, &mut renderer)?;
let current_body = read_existing_text(dst_abs.as_path())?;
let mode = for_how(spec.how);
let action_ctx = ActionContext {
project: &project,
pj_root: pj_root.as_path(),
template: handle,
spec,
src_abs,
dst_abs: dst_abs.clone(),
rendered_body,
current_body,
vars,
tera_ctx: &ctx,
agent: agent.clone(),
agent_backend: opts.agent_backend,
interactive: opts.interactive,
yes_all: opts.yes_all,
ai_prompt: opts.ai_prompt.as_deref(),
ai_mode_override: opts.ai_mode_override,
ai_sema: ai_sema.clone(),
};
let outcome = match mode.execute(&action_ctx, opts.dry_run).await {
Ok(o) => o,
Err(e) => {
errors.push((dst_rel.clone(), e.to_string()));
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, OutcomeKind::Failed));
continue;
}
};
if matches!(outcome.kind, OutcomeKind::Failed) {
let msg = outcome
.error
.clone()
.unwrap_or_else(|| "failed (no error message)".to_string());
errors.push((dst_rel.clone(), msg));
}
if !opts.dry_run && matches!(outcome.kind, OutcomeKind::Wrote | OutcomeKind::Unchanged)
{
let is_once = matches!(spec.when, WhenMode::Once);
let mut fs = applied.files.get(&state_key).cloned().unwrap_or_default();
if is_once {
fs.content_hash = None;
} else {
fs.content_hash = Some(hash_content(action_ctx.rendered_body.as_bytes()));
}
applied.record(&state_key, fs);
if is_once {
once_applied_dsts.insert(state_key.clone());
}
wrote_in_run.insert(state_key.clone());
if matches!(outcome.kind, OutcomeKind::Wrote) {
has_any_write = true;
}
}
action_indices_by_dst
.entry(state_key.clone())
.or_default()
.push(actions.len());
actions.push((dst_rel, outcome.kind));
}
}
if !opts.dry_run {
for dst in &once_applied_dsts {
let mut fs = applied.files.get(dst).cloned().unwrap_or_default();
fs.once_applied = true;
applied.record(dst, fs);
}
}
if !opts.dry_run {
for (dst_key, indices) in &action_indices_by_dst {
if indices.len() < 2 {
continue;
}
let has_wrote = indices.iter().any(|&i| {
actions
.get(i)
.is_some_and(|(_, k)| matches!(k, OutcomeKind::Wrote))
});
if !has_wrote {
continue;
}
let dst_abs = pj_root.join(dst_key);
let final_disk = if dst_abs.is_file() {
read_existing_text(dst_abs.as_path())?
} else {
None
};
let initial = initial_disk_by_dst
.get(dst_key)
.cloned()
.unwrap_or_default();
if initial == final_disk {
for &i in indices {
if let Some(slot) = actions.get_mut(i) {
if matches!(slot.1, OutcomeKind::Wrote) {
slot.1 = OutcomeKind::Unchanged;
}
}
}
}
}
has_any_write = actions.iter().any(|(_, k)| matches!(k, OutcomeKind::Wrote));
}
if !opts.dry_run {
applied.preset = preset_spec;
applied.base_dir = Some(base_dir);
applied.templates = applied_templates;
if has_any_write {
applied.applied_at = Some(Timestamp::now());
}
applied.vars = resolved
.values
.iter()
.filter(|(k, _)| {
resolved
.sources
.get(k.as_str())
.copied()
.is_some_and(|s| s.should_persist_in_applied())
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
applied.save(&pj_root)?;
}
Ok(PjApplyResult {
project_name: project.name.clone(),
actions,
errors,
})
}
fn render_dst(
renderer: &mut Renderer,
spec: &crate::manifest::FileSpec,
ctx: &tera::Context,
) -> Result<String> {
let raw = spec.dst_or_src();
if !raw.contains("{{") && !raw.contains("{%") {
return Ok(raw.to_string());
}
renderer.render(raw, ctx)
}
fn eval_truthy(renderer: &mut Renderer, expr: &str, ctx: &tera::Context) -> Result<bool> {
let wrapped = format!("{{% if {expr} %}}1{{% else %}}0{{% endif %}}");
let out = renderer.render(&wrapped, ctx)?;
Ok(out.trim() == "1")
}
pub fn hash_content(b: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(b);
let bytes = h.finalize();
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes.iter() {
use std::fmt::Write;
let _ = write!(s, "{byte:02x}");
}
s
}
pub async fn plan_pj(
project: ProjectEntry,
pj_root: Utf8PathBuf,
templates: Vec<TemplateRef>,
base_dir: Utf8PathBuf,
preset_vars: toml::Table,
interactive: bool,
cli_vars: BTreeMap<String, toml::Value>,
) -> Result<Vec<(String, crate::modes::PlanKind, Option<String>)>> {
let applied = AppliedState::load(&pj_root)?;
let plan_sema = Arc::new(Semaphore::new(1));
let mut handles: Vec<TemplateHandle> = Vec::with_capacity(templates.len());
for t in &templates {
handles.push(TemplateHandle::load(t, &base_dir).await?);
}
let mut all_specs: BTreeMap<String, VarSpec> = BTreeMap::new();
for h in &handles {
for (k, v) in &h.manifest.vars {
all_specs.insert(k.clone(), v.clone());
}
}
let env_vars = VarSources::from_env();
let vars_file = VarSources::load_vars_file(&pj_root)?;
let template_seed = collect_template_seed_vars(&handles)?;
let sources = VarSources {
cli: cli_vars,
env: env_vars,
vars_file,
applied: applied.vars.clone(),
preset: preset_vars,
template_seed,
};
let resolver = VarResolver {
specs: &all_specs,
sources: &sources,
interactive,
prompter: |name: &str, spec: &VarSpec| crate::interactive::prompt_var(name, spec),
};
let resolved = resolver.resolve()?;
let vars = &resolved.values;
let ctx = build_context(&project, &pj_root, vars);
let mut renderer = Renderer::new();
let mut out = Vec::new();
for handle in &handles {
for spec in &handle.manifest.files {
check_relative_contained(&spec.src, "template src")?;
let dst_rel = render_dst(&mut renderer, spec, &ctx)?;
check_relative_contained(&dst_rel, "destination")?;
let dst_abs = pj_root.join(&dst_rel);
let src_abs = handle.root.join(&spec.src);
if spec.when == WhenMode::Once {
if let Some(s) = applied.files.get(&dst_rel) {
if s.once_applied {
out.push((dst_rel, crate::modes::PlanKind::SkippedOnce, None));
continue;
}
}
if dst_abs.is_file() {
out.push((dst_rel, crate::modes::PlanKind::AdoptedExisting, None));
continue;
}
if dst_abs.exists() {
out.push((dst_rel, crate::modes::PlanKind::Diverged, None));
continue;
}
}
if spec.when == WhenMode::Manual {
out.push((dst_rel, crate::modes::PlanKind::SkippedWhen, None));
continue;
}
if let Some(expr) = &spec.when_expr {
if !eval_truthy(&mut renderer, expr, &ctx)? {
out.push((dst_rel, crate::modes::PlanKind::SkippedWhen, None));
continue;
}
}
let raw = match std::fs::read_to_string(src_abs.as_std_path()) {
Ok(s) => s,
Err(_) => {
out.push((dst_rel, crate::modes::PlanKind::Diverged, None));
continue;
}
};
let rendered_body = render_or_passthrough(spec, raw, &ctx, &mut renderer)?;
let current_body = read_existing_text(dst_abs.as_path())?;
let mode = for_how(spec.how);
let action_ctx = ActionContext {
project: &project,
pj_root: pj_root.as_path(),
template: handle,
spec,
src_abs,
dst_abs: dst_abs.clone(),
rendered_body,
current_body,
vars,
tera_ctx: &ctx,
agent: None,
agent_backend: None,
interactive,
yes_all: false,
ai_prompt: None,
ai_mode_override: None,
ai_sema: plan_sema.clone(),
};
let plan = mode.plan(&action_ctx).await?;
out.push((dst_rel, plan.kind, plan.diff));
}
}
Ok(out)
}
fn collect_template_seed_vars(handles: &[TemplateHandle]) -> Result<toml::Table> {
let mut seed = toml::Table::new();
for handle in handles {
let mut layer_specs: Vec<&crate::manifest::FileSpec> = handle
.manifest
.files
.iter()
.filter(|spec| spec_is_vars_seed(spec))
.collect();
layer_specs.sort_by(|a, b| {
let dst_a = a.dst_or_src();
let dst_b = b.dst_or_src();
let is_bare_a = dst_a.ends_with("/vars.toml") || dst_a == "vars.toml";
let is_bare_b = dst_b.ends_with("/vars.toml") || dst_b == "vars.toml";
match (is_bare_a, is_bare_b) {
(true, true) => std::cmp::Ordering::Equal,
(true, false) => std::cmp::Ordering::Greater,
(false, true) => std::cmp::Ordering::Less,
(false, false) => dst_a.cmp(dst_b),
}
});
for spec in layer_specs {
check_relative_contained(&spec.src, "template src")?;
let src_abs = handle.root.join(&spec.src);
let content = match std::fs::read_to_string(src_abs.as_std_path()) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(Error::io_at(src_abs.as_std_path(), e)),
};
let parsed: toml::Table = toml::from_str(&content)
.map_err(|e| Error::Config(format!("parse template seed `{src_abs}`: {e}")))?;
deep_merge_table(&mut seed, parsed);
}
}
Ok(seed)
}
fn spec_is_vars_seed(spec: &crate::manifest::FileSpec) -> bool {
use crate::render::vars::{KATA_DIR_REL, matches_vars_pattern};
let normalized = normalize_relative_path(spec.dst_or_src());
let prefix = format!("{KATA_DIR_REL}/");
let Some(name) = normalized.strip_prefix(prefix.as_str()) else {
return false;
};
if name.contains('/') {
return false;
}
matches_vars_pattern(name)
}
fn normalize_relative_path(s: &str) -> String {
use std::path::{Component, Path, PathBuf};
let unified: String = s.chars().map(|c| if c == '\\' { '/' } else { c }).collect();
let mut buf = PathBuf::new();
for c in Path::new(&unified).components() {
match c {
Component::CurDir => continue,
Component::ParentDir => {
if !buf.pop() {
return String::new();
}
}
Component::Normal(seg) => buf.push(seg),
Component::RootDir | Component::Prefix(_) => {
return String::new();
}
}
}
buf.to_string_lossy().replace('\\', "/")
}
fn render_or_passthrough(
spec: &FileSpec,
raw: String,
ctx: &tera::Context,
renderer: &mut Renderer,
) -> Result<String> {
if spec.is_tera_source() {
renderer.render(&raw, ctx)
} else {
Ok(raw)
}
}
fn read_existing_text(path: &camino::Utf8Path) -> Result<Option<String>> {
match std::fs::read_to_string(path.as_std_path()) {
Ok(body) => Ok(Some(body)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::io_at(path.as_std_path(), e)),
}
}
fn check_relative_contained(rel: &str, kind: &str) -> Result<()> {
use std::path::{Component, Path};
let p = Path::new(rel);
if p.is_absolute() {
return Err(Error::Other(anyhow::anyhow!(
"{kind} path `{rel}` must be relative, not absolute"
)));
}
let mut depth: i32 = 0;
for comp in p.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
depth -= 1;
if depth < 0 {
return Err(Error::Other(anyhow::anyhow!(
"{kind} path `{rel}` escapes its root via `..`"
)));
}
}
Component::Normal(_) => depth += 1,
Component::RootDir | Component::Prefix(_) => {
return Err(Error::Other(anyhow::anyhow!(
"{kind} path `{rel}` must be relative"
)));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn check_contained_accepts_simple_relative() {
assert!(check_relative_contained("Makefile.toml", "x").is_ok());
assert!(check_relative_contained("src/main.rs", "x").is_ok());
assert!(check_relative_contained("a/b/c.txt", "x").is_ok());
assert!(check_relative_contained("./Makefile.toml", "x").is_ok());
assert!(check_relative_contained("a/./b", "x").is_ok());
assert!(check_relative_contained("a/b/../c", "x").is_ok());
}
#[test]
fn check_contained_rejects_traversal() {
assert!(check_relative_contained("../etc/passwd", "x").is_err());
assert!(check_relative_contained("a/../../escape", "x").is_err());
assert!(check_relative_contained("./../bad", "x").is_err());
}
#[test]
fn check_contained_rejects_absolute() {
assert!(check_relative_contained("/etc/passwd", "x").is_err());
if cfg!(windows) {
assert!(check_relative_contained(r"C:\Windows\System32", "x").is_err());
}
}
}