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,
}
#[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;
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 spec.when == WhenMode::Once && !opts.force_once {
if let Some(state) = applied.files.get(&state_key) {
if state.once_applied {
actions.push((dst_rel, OutcomeKind::Skipped));
continue;
}
}
if dst_abs.is_file() {
if !opts.dry_run {
let mut fs = applied.files.get(&state_key).cloned().unwrap_or_default();
fs.once_applied = true;
fs.content_hash = None;
applied.record(&state_key, fs);
}
actions.push((dst_rel, OutcomeKind::Adopted));
continue;
}
if dst_abs.exists() {
let msg = format!("destination exists but is not a regular file: {dst_abs}");
errors.push((dst_rel.clone(), msg));
actions.push((dst_rel, OutcomeKind::Failed));
continue;
}
}
if spec.when == WhenMode::Manual {
actions.push((dst_rel, OutcomeKind::Skipped));
continue;
}
if let Some(expr) = &spec.when_expr {
if !eval_truthy(&mut renderer, expr, &ctx)? {
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}")));
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()));
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 once_applied = matches!(spec.when, WhenMode::Once);
let mut fs = applied.files.get(&state_key).cloned().unwrap_or_default();
fs.once_applied = fs.once_applied || once_applied;
if once_applied {
fs.content_hash = None;
} else {
fs.content_hash = Some(hash_content(action_ctx.rendered_body.as_bytes()));
}
applied.record(&state_key, fs);
if matches!(outcome.kind, OutcomeKind::Wrote) {
has_any_write = true;
}
}
actions.push((dst_rel, outcome.kind));
}
}
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> {
const VARS_FILE_REL: &str = ".kata/vars.toml";
let mut seed = toml::Table::new();
for handle in handles {
for spec in &handle.manifest.files {
let effective_dst: std::borrow::Cow<'_, str> = match &spec.dst {
Some(d) => std::borrow::Cow::Borrowed(d.as_str()),
None => std::borrow::Cow::Borrowed(
spec.src.strip_suffix(".tera").unwrap_or(spec.src.as_str()),
),
};
if effective_dst != VARS_FILE_REL {
continue;
}
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 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());
}
}
}