use std::path::Path;
pub const BEGIN_MARKER: &str = "<!-- lific:begin -->";
pub const END_MARKER: &str = "<!-- lific:end -->";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentsAction {
Created,
Inserted,
Replaced,
}
impl AgentsAction {
pub fn as_str(self) -> &'static str {
match self {
AgentsAction::Created => "created",
AgentsAction::Inserted => "inserted",
AgentsAction::Replaced => "replaced",
}
}
}
pub fn render_block(project: Option<&str>) -> String {
let ident = project.unwrap_or("APP");
let ident_note = if project.is_some() {
String::new()
} else {
"\n> Replace `APP` with this repo's project identifier — run `lific project list` to find it.\n"
.to_string()
};
format!(
"{BEGIN_MARKER}\n\
## Issue tracking: Lific\n\
\n\
This project uses **Lific** for issue tracking and project management (local-first, \
single-binary, SQLite-backed).\n\
{ident_note}\
\n\
**Preferred access — MCP.** If a Lific MCP server is configured in your client \
(see the tools/MCP config in this repo or your global config), use it directly: \
list/create/update issues, pages, and plans through the Lific tools.\n\
\n\
**CLI fallback** (works without MCP; add `--json` for machine-readable output):\n\
\n\
```bash\n\
lific issue list --project {ident} --json # browse issues\n\
lific issue get {ident}-1 # one issue with relations\n\
lific issue update {ident}-1 --status done # close an issue\n\
lific search \"auth flow\" --project {ident} # full-text search\n\
```\n\
\n\
**Conventions:**\n\
- Mark an issue `done` as soon as you finish its work — don't leave it open.\n\
- Group related work with **modules**; fit each issue into the right module.\n\
- For multi-session work, use a **plan** so the next session can resume.\n\
- Issues are self-contained work items; keep scope tight.\n\
{END_MARKER}"
)
}
pub fn write(path: &Path, project: Option<&str>) -> Result<AgentsAction, String> {
let block = render_block(project);
let existing = match std::fs::read_to_string(path) {
Ok(s) => Some(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(format!("failed to read {}: {e}", path.display())),
};
let (contents, action) = match existing {
None => (format!("{block}\n"), AgentsAction::Created),
Some(current) => merge(¤t, &block)?,
};
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {e}", parent.display()))?;
}
std::fs::write(path, contents).map_err(|e| format!("failed to write {}: {e}", path.display()))?;
Ok(action)
}
fn merge(current: &str, block: &str) -> Result<(String, AgentsAction), String> {
if let (Some(begin), Some(end_start)) = (current.find(BEGIN_MARKER), current.find(END_MARKER)) {
if end_start < begin {
return Err(
"AGENTS.md has a lific:end marker before lific:begin — refusing to edit; fix the markers by hand"
.into(),
);
}
let end = end_start + END_MARKER.len();
let mut out = String::with_capacity(current.len() + block.len());
out.push_str(¤t[..begin]);
out.push_str(block);
out.push_str(¤t[end..]);
Ok((out, AgentsAction::Replaced))
} else {
let mut out = current.trim_end().to_string();
if !out.is_empty() {
out.push_str("\n\n");
}
out.push_str(block);
out.push('\n');
Ok((out, AgentsAction::Inserted))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp() -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"lific-agents-md-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn block_with_project_bakes_identifier() {
let block = render_block(Some("LIF"));
assert!(block.contains("lific issue list --project LIF --json"));
assert!(block.contains("lific issue update LIF-1 --status done"));
assert!(!block.contains("project list` to find it"));
}
#[test]
fn block_without_project_uses_placeholder_and_note() {
let block = render_block(None);
assert!(block.contains("APP"));
assert!(block.contains("lific project list"));
}
#[test]
fn creates_file_when_absent() {
let dir = tmp();
let path = dir.join("AGENTS.md");
let action = write(&path, Some("LIF")).unwrap();
assert_eq!(action, AgentsAction::Created);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains(BEGIN_MARKER));
assert!(content.contains(END_MARKER));
assert!(content.contains("LIF-1"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn inserts_after_existing_content_preserving_it() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("AGENTS.md");
let original = "# My Project\n\nSome existing agent instructions.\n";
std::fs::write(&path, original).unwrap();
let action = write(&path, Some("LIF")).unwrap();
assert_eq!(action, AgentsAction::Inserted);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("# My Project"));
assert!(content.contains("Some existing agent instructions."));
assert!(content.contains(BEGIN_MARKER));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn rerun_is_idempotent_single_block() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("AGENTS.md");
std::fs::write(&path, "# Header\n\nBody.\n").unwrap();
write(&path, Some("LIF")).unwrap();
let after_first = std::fs::read_to_string(&path).unwrap();
let action = write(&path, Some("LIF")).unwrap();
assert_eq!(action, AgentsAction::Replaced);
let after_second = std::fs::read_to_string(&path).unwrap();
assert_eq!(after_first.matches(BEGIN_MARKER).count(), 1);
assert_eq!(after_second.matches(BEGIN_MARKER).count(), 1);
assert_eq!(after_first, after_second);
assert!(after_second.starts_with("# Header"));
assert!(after_second.contains("Body."));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn replace_updates_project_identifier_and_preserves_surroundings() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("AGENTS.md");
std::fs::write(&path, "Top.\n").unwrap();
write(&path, Some("OLD")).unwrap();
let with_trailer = format!("{}\n\nTrailing note.\n", std::fs::read_to_string(&path).unwrap().trim_end());
std::fs::write(&path, with_trailer).unwrap();
let action = write(&path, Some("NEW")).unwrap();
assert_eq!(action, AgentsAction::Replaced);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("--project NEW"));
assert!(!content.contains("--project OLD"));
assert!(content.starts_with("Top."));
assert!(content.contains("Trailing note."));
assert_eq!(content.matches(BEGIN_MARKER).count(), 1);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn inverted_markers_error() {
let dir = tmp();
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("AGENTS.md");
std::fs::write(&path, format!("{END_MARKER}\nx\n{BEGIN_MARKER}\n")).unwrap();
let err = write(&path, Some("LIF")).unwrap_err();
assert!(err.contains("marker"));
std::fs::remove_dir_all(&dir).ok();
}
}