use bake::{Error, Result};
use std::collections::BTreeSet;
use std::ffi::OsStr;
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>> {
local_exclude_path_with(root, OsStr::new("git"))
}
fn local_exclude_path_with(root: &Path, executable: &OsStr) -> Result<Option<PathBuf>> {
let output = match Command::new(executable)
.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);
}
exclude_path_from_output(root, output.status.success(), &output.stdout)
}
fn exclude_path_from_output(root: &Path, success: bool, stdout: &[u8]) -> Result<Option<PathBuf>> {
if !success {
return Ok(None);
}
let path = String::from_utf8(stdout.to_vec())
.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<_>>();
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)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn update_apply_is_idempotent_and_reports_parent_and_write_errors() {
let directory = tempdir().unwrap();
let unchanged = directory.path().join("unchanged");
Update {
path: unchanged.clone(),
original: "same".to_owned(),
updated: "same".to_owned(),
}
.apply()
.unwrap();
assert!(!unchanged.exists());
let parent_file = directory.path().join("file");
fs::write(&parent_file, "not a directory").unwrap();
let error = Update {
path: parent_file.join("exclude"),
original: String::new(),
updated: "generated\n".to_owned(),
}
.apply()
.unwrap_err();
assert!(error.to_string().contains("cannot create"));
let destination = directory.path().join("directory");
fs::create_dir(&destination).unwrap();
let error = Update {
path: destination.clone(),
original: "old".to_owned(),
updated: "new".to_owned(),
}
.apply()
.unwrap_err();
assert!(error.to_string().contains("cannot update"));
let error = Update {
path: PathBuf::new(),
original: "old".to_owned(),
updated: "new".to_owned(),
}
.apply()
.unwrap_err();
assert!(error.to_string().contains("cannot update"));
}
#[test]
fn resolves_git_output_and_rejects_invalid_utf8() {
let directory = tempdir().unwrap();
let root = directory.path();
assert_eq!(
exclude_path_from_output(root, false, b"ignored").unwrap(),
None
);
assert_eq!(
exclude_path_from_output(root, true, b".git/info/exclude\n").unwrap(),
Some(root.join(".git/info/exclude"))
);
let absolute = root.join("exclude");
assert_eq!(
exclude_path_from_output(root, true, absolute.to_str().unwrap().as_bytes()).unwrap(),
Some(absolute)
);
assert!(exclude_path_from_output(root, true, &[0xff]).is_err());
}
#[test]
fn prepares_and_applies_managed_rules_without_losing_user_rules() {
let directory = tempdir().unwrap();
let path = directory.path().join("exclude");
let original = "# user rule\n*.local\n\n";
fs::write(&path, original).unwrap();
let update = prepare_local_exclude(
path.clone(),
&BTreeSet::from(["zeta-skill".to_owned(), "alpha-skill".to_owned()]),
)
.unwrap();
update.apply().unwrap();
let result = fs::read_to_string(&path).unwrap();
assert!(result.starts_with("# user rule\n*.local\n\n# BEGIN bake-agent-context\n"));
assert!(result.find("alpha-skill").unwrap() < result.find("zeta-skill").unwrap());
let repeated = prepare_local_exclude(
path.clone(),
&BTreeSet::from(["alpha-skill".to_owned(), "zeta-skill".to_owned()]),
)
.unwrap();
repeated.apply().unwrap();
assert_eq!(fs::read_to_string(path).unwrap(), result);
}
#[test]
fn handles_empty_exclude_files_and_rejects_broken_managed_blocks() {
let directory = tempdir().unwrap();
let path = directory.path().join("exclude");
let empty = prepare_local_exclude(path.clone(), &BTreeSet::new()).unwrap();
empty.apply().unwrap();
let just_block = fs::read_to_string(&path).unwrap();
assert!(just_block.starts_with(BEGIN_MARKER));
for contents in [
BEGIN_MARKER.to_owned(),
END_MARKER.to_owned(),
format!("{END_MARKER}\n{BEGIN_MARKER}\n"),
format!("{BEGIN_MARKER}\n{END_MARKER}\n{BEGIN_MARKER}\n{END_MARKER}\n"),
] {
assert!(
remove_managed_block(&contents, &path).is_err(),
"{contents:?}"
);
}
assert_eq!(
remove_managed_block(&format!("{BEGIN_MARKER}\n{END_MARKER}\n"), &path).unwrap(),
""
);
assert_eq!(remove_managed_block("user\n\n", &path).unwrap(), "user\n");
}
#[test]
fn reports_exclude_read_errors() {
let directory = tempdir().unwrap();
let path = directory.path().join("exclude-directory");
fs::create_dir(&path).unwrap();
let error = prepare_local_exclude(path, &BTreeSet::new()).err().unwrap();
assert!(error.to_string().contains("cannot read"));
}
#[cfg(unix)]
#[test]
fn handles_git_command_start_errors() {
let directory = tempdir().unwrap();
assert_eq!(
local_exclude_path_with(directory.path(), OsStr::new("missing-git-executable"))
.unwrap(),
None
);
let error = local_exclude_path_with(directory.path(), directory.path().as_os_str())
.err()
.unwrap();
assert!(error.to_string().contains("cannot locate Git exclude file"));
}
}