use bake::{Error, Result};
use std::collections::BTreeSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const BEGIN_MARKER: &str = "# BEGIN bake-agent-context";
const END_MARKER: &str = "# END bake-agent-context";
pub(crate) struct Update {
path: PathBuf,
original: String,
updated: String,
}
impl Update {
pub(crate) fn apply(self) -> Result<()> {
if self.original == self.updated {
return Ok(());
}
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(|error| {
Error::new(format!("cannot create {}: {error}", parent.display()))
})?;
}
fs::write(&self.path, self.updated)
.map_err(|error| Error::new(format!("cannot update {}: {error}", self.path.display())))
}
}
pub(crate) fn prepare(
root: &Path,
skill_names: impl IntoIterator<Item = String>,
) -> Result<Option<Update>> {
let Some(path) = local_exclude_path(root)? else {
return Ok(None);
};
let skill_names: BTreeSet<_> = skill_names.into_iter().collect();
Ok(Some(prepare_local_exclude(path, &skill_names)?))
}
fn local_exclude_path(root: &Path) -> Result<Option<PathBuf>> {
let output = match Command::new("git")
.args(["rev-parse", "--git-path", "info/exclude"])
.current_dir(root)
.output()
{
Ok(output) => output,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(Error::new(format!(
"cannot locate Git exclude file: {error}"
)));
}
};
if !output.status.success() {
return Ok(None);
}
let path = String::from_utf8(output.stdout)
.map_err(|error| Error::new(format!("Git returned an invalid exclude path: {error}")))?;
let path = PathBuf::from(path.trim());
Ok(Some(if path.is_absolute() {
path
} else {
root.join(path)
}))
}
fn prepare_local_exclude(path: PathBuf, skill_names: &BTreeSet<String>) -> Result<Update> {
let original = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(Error::new(format!(
"cannot read {}: {error}",
path.display()
)));
}
};
let mut lines = remove_managed_block(&original, &path)?
.lines()
.map(str::to_owned)
.collect::<Vec<_>>();
while lines.last().is_some_and(|line| line.is_empty()) {
lines.pop();
}
if !lines.is_empty() {
lines.push(String::new());
}
let mut block = vec![
BEGIN_MARKER.to_owned(),
"# Generated by Bake Agent Context; do not edit this block manually.".to_owned(),
"/.agents/context/".to_owned(),
"/.agents/skills/.agent-context-skills.json".to_owned(),
];
block.extend(
skill_names
.iter()
.map(|name| format!("/.agents/skills/{name}/")),
);
block.push(END_MARKER.to_owned());
lines.extend(block);
let mut updated = lines.join("\n");
updated.push('\n');
Ok(Update {
path,
original,
updated,
})
}
fn remove_managed_block(contents: &str, path: &Path) -> Result<String> {
let mut lines: Vec<String> = contents.lines().map(str::to_owned).collect();
let begin_positions: Vec<_> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.trim() == BEGIN_MARKER)
.map(|(index, _)| index)
.collect();
let end_positions: Vec<_> = lines
.iter()
.enumerate()
.filter(|(_, line)| line.trim() == END_MARKER)
.map(|(index, _)| index)
.collect();
match (begin_positions.as_slice(), end_positions.as_slice()) {
([], []) => {}
([begin], [end]) if begin < end => {
lines.drain(*begin..=*end);
}
_ => {
return Err(Error::new(format!(
"{} has an incomplete or duplicated bake-agent-context ignore block",
path.display()
)));
}
}
while lines.last().is_some_and(|line| line.is_empty()) {
lines.pop();
}
if lines.is_empty() {
Ok(String::new())
} else {
let mut output = lines.join("\n");
output.push('\n');
Ok(output)
}
}