use std::path::PathBuf;
use async_trait::async_trait;
use crate::error::{Error, Result};
use crate::manifest::MarkerSpec;
use super::{
ActionContext, ActionOutcome, ActionPlan, ApplyMode, OutcomeKind, PlanKind, unified_diff,
};
pub struct MergeSection;
#[async_trait]
impl ApplyMode for MergeSection {
async fn plan(&self, ctx: &ActionContext<'_>) -> Result<ActionPlan> {
let marker = require_marker(ctx)?;
match compose(marker, ctx.current_body.as_deref(), &ctx.rendered_body) {
ComposeResult::Create(new_body) => Ok(ActionPlan {
kind: PlanKind::Create,
diff: Some(unified_diff("", &new_body, ctx.dst_abs.as_str())),
}),
ComposeResult::Unchanged => Ok(ActionPlan {
kind: PlanKind::Unchanged,
diff: None,
}),
ComposeResult::Update { current, new_body } => Ok(ActionPlan {
kind: PlanKind::Update,
diff: Some(unified_diff(¤t, &new_body, ctx.dst_abs.as_str())),
}),
ComposeResult::Diverged => Ok(ActionPlan {
kind: PlanKind::Diverged,
diff: Some(format!(
"(only one of `{}` / `{}` present in {} — fix manually)",
marker.begin, marker.end, ctx.dst_abs
)),
}),
}
}
async fn execute(&self, ctx: &ActionContext<'_>, dry_run: bool) -> Result<ActionOutcome> {
let marker = require_marker(ctx)?;
let composed = compose(marker, ctx.current_body.as_deref(), &ctx.rendered_body);
match composed {
ComposeResult::Unchanged => Ok(ActionOutcome {
kind: OutcomeKind::Unchanged,
decision: None,
diff: None,
error: None,
}),
ComposeResult::Diverged => Ok(ActionOutcome {
kind: OutcomeKind::Failed,
decision: None,
diff: None,
error: Some(format!(
"merge-section: only one of `{}` / `{}` present in {}; fix manually",
marker.begin, marker.end, ctx.dst_abs
)),
}),
ComposeResult::Create(new_body) | ComposeResult::Update { new_body, .. } if dry_run => {
Ok(ActionOutcome {
kind: OutcomeKind::Skipped,
decision: None,
diff: Some(unified_diff(
ctx.current_body.as_deref().unwrap_or(""),
&new_body,
ctx.dst_abs.as_str(),
)),
error: None,
})
}
ComposeResult::Create(new_body) | ComposeResult::Update { new_body, .. } => {
let diff = unified_diff(
ctx.current_body.as_deref().unwrap_or(""),
&new_body,
ctx.dst_abs.as_str(),
);
if let Some(parent) = ctx.dst_abs.parent() {
tokio::fs::create_dir_all(parent.as_std_path())
.await
.map_err(|e| Error::io_at(parent.as_std_path(), e))?;
}
tokio::fs::write(ctx.dst_abs.as_std_path(), &new_body)
.await
.map_err(|e| Error::io_at(ctx.dst_abs.as_std_path(), e))?;
Ok(ActionOutcome {
kind: OutcomeKind::Wrote,
decision: None,
diff: Some(diff),
error: None,
})
}
}
}
}
#[derive(Debug)]
enum ComposeResult {
Create(String),
Update { current: String, new_body: String },
Unchanged,
Diverged,
}
fn compose(marker: &MarkerSpec, current: Option<&str>, body: &str) -> ComposeResult {
let block = marker_block(marker, body);
let current = match current {
None => return ComposeResult::Create(format!("{block}\n")),
Some(c) => c,
};
let begin = current.find(&marker.begin);
let end = current.find(&marker.end);
match (begin, end) {
(Some(bs), Some(es)) if es >= bs + marker.begin.len() => {
let end_pos = es + marker.end.len();
let mut new_body = current[..bs].to_string();
new_body.push_str(&block);
new_body.push_str(¤t[end_pos..]);
if new_body == current {
ComposeResult::Unchanged
} else {
ComposeResult::Update {
current: current.to_string(),
new_body,
}
}
}
(Some(_), None) | (None, Some(_)) => ComposeResult::Diverged,
(Some(_), Some(_)) => {
ComposeResult::Diverged
}
(None, None) => {
let sep = if current.ends_with('\n') || current.is_empty() {
""
} else {
"\n"
};
let new_body = format!("{current}{sep}{block}\n");
if new_body == current {
ComposeResult::Unchanged
} else {
ComposeResult::Update {
current: current.to_string(),
new_body,
}
}
}
}
}
fn marker_block(marker: &MarkerSpec, body: &str) -> String {
format!(
"{}\n{}\n{}",
marker.begin,
body.trim_end_matches('\n'),
marker.end
)
}
fn require_marker<'a>(ctx: &'a ActionContext<'_>) -> Result<&'a MarkerSpec> {
ctx.spec.marker.as_ref().ok_or_else(|| {
Error::manifest(
PathBuf::from(&ctx.template.source_spec),
format!(
"how=\"merge-section\" requires a `marker` table in `[[file]]` for {}",
ctx.spec.src
),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn marker() -> MarkerSpec {
MarkerSpec {
begin: "# >>> kata managed <<<".to_string(),
end: "# <<< kata managed >>>".to_string(),
}
}
#[test]
fn create_when_dst_absent() {
let r = compose(&marker(), None, "[deps]\nx = 1\n");
match r {
ComposeResult::Create(body) => {
assert!(body.starts_with("# >>> kata managed <<<\n"));
assert!(body.contains("[deps]\nx = 1"));
assert!(body.contains("\n# <<< kata managed >>>\n"));
}
other => panic!("expected Create, got {other:?}"),
}
}
#[test]
fn replace_existing_block_in_place() {
let cur = "# header\n# >>> kata managed <<<\nold\n# <<< kata managed >>>\n# footer\n";
let r = compose(&marker(), Some(cur), "new");
match r {
ComposeResult::Update { new_body, .. } => {
assert!(new_body.contains("# header"));
assert!(new_body.contains("# >>> kata managed <<<\nnew\n# <<< kata managed >>>"));
assert!(new_body.contains("# footer"));
assert!(!new_body.contains("old"));
}
other => panic!("expected Update, got {other:?}"),
}
}
#[test]
fn unchanged_when_existing_block_matches() {
let cur = "before\n# >>> kata managed <<<\nbody\n# <<< kata managed >>>\nafter\n";
let r = compose(&marker(), Some(cur), "body\n");
assert!(matches!(r, ComposeResult::Unchanged));
}
#[test]
fn append_when_no_marker_in_existing() {
let cur = "manual content\n";
let r = compose(&marker(), Some(cur), "managed");
match r {
ComposeResult::Update { new_body, .. } => {
assert!(new_body.starts_with("manual content\n"));
assert!(
new_body.contains("# >>> kata managed <<<\nmanaged\n# <<< kata managed >>>\n")
);
}
other => panic!("expected Update (append), got {other:?}"),
}
}
#[test]
fn append_inserts_separator_when_existing_lacks_trailing_newline() {
let cur = "no trailing newline";
let r = compose(&marker(), Some(cur), "managed");
match r {
ComposeResult::Update { new_body, .. } => {
assert!(new_body.starts_with("no trailing newline\n# >>> kata managed <<<"));
}
other => panic!("expected Update, got {other:?}"),
}
}
#[test]
fn diverged_when_only_begin_marker_present() {
let cur = "# >>> kata managed <<<\nbody but no end marker\n";
let r = compose(&marker(), Some(cur), "body");
assert!(matches!(r, ComposeResult::Diverged));
}
#[test]
fn diverged_when_only_end_marker_present() {
let cur = "no begin marker\n# <<< kata managed >>>\n";
let r = compose(&marker(), Some(cur), "body");
assert!(matches!(r, ComposeResult::Diverged));
}
#[test]
fn diverged_when_markers_swapped() {
let cur = "# <<< kata managed >>>\nbackwards\n# >>> kata managed <<<\n";
let r = compose(&marker(), Some(cur), "body");
assert!(matches!(r, ComposeResult::Diverged));
}
#[test]
fn diverged_when_begin_and_end_are_identical_literals() {
let m = MarkerSpec {
begin: "// SAME //".to_string(),
end: "// SAME //".to_string(),
};
let cur = "before\n// SAME //\nafter\n";
let r = compose(&m, Some(cur), "body");
assert!(matches!(r, ComposeResult::Diverged));
}
}