use std::path::Path;
use anyhow::{Context, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommentSyntax {
pub open: &'static str,
pub close: Option<&'static str>,
}
impl CommentSyntax {
pub fn wrap(&self, body: &str) -> String {
match self.close {
Some(close) => format!("{} {} {}", self.open, body, close),
None => format!("{} {}", self.open, body),
}
}
pub fn render_scar(&self, kind: ScarKind, body: &str) -> String {
self.wrap(&format!("@kizu[{}]: {body}", kind.tag()))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScarKind {
Ask,
Reject,
Free,
}
impl ScarKind {
pub fn tag(self) -> &'static str {
match self {
ScarKind::Ask => "ask",
ScarKind::Reject => "reject",
ScarKind::Free => "free",
}
}
}
const SLASH_SLASH: CommentSyntax = CommentSyntax {
open: "//",
close: None,
};
const HASH: CommentSyntax = CommentSyntax {
open: "#",
close: None,
};
const HTML: CommentSyntax = CommentSyntax {
open: "<!--",
close: Some("-->"),
};
const CSS: CommentSyntax = CommentSyntax {
open: "/*",
close: Some("*/"),
};
const DASH_DASH: CommentSyntax = CommentSyntax {
open: "--",
close: None,
};
pub fn detect_comment_syntax(path: &Path) -> CommentSyntax {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return HASH;
};
let ext_lower = ext.to_ascii_lowercase();
match ext_lower.as_str() {
"rs" | "ts" | "tsx" | "js" | "jsx" | "java" | "go" | "c" | "cpp" | "cc" | "h" | "hpp"
| "swift" | "kt" | "kts" | "scala" | "dart" => SLASH_SLASH,
"rb" | "py" | "sh" | "bash" | "zsh" | "fish" | "yaml" | "yml" | "toml" | "ini" | "conf"
| "r" | "pl" | "ex" | "exs" => HASH,
"html" | "htm" | "xml" | "svg" | "vue" | "md" => HTML,
"css" | "scss" | "sass" | "less" => CSS,
"sql" | "lua" | "hs" | "ada" | "sqlite" => DASH_DASH,
_ => HASH,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScarInsert {
pub line_1indexed: usize,
pub rendered: String,
}
pub fn insert_scar(
path: &Path,
line_number: usize,
kind: ScarKind,
body: &str,
) -> Result<Option<ScarInsert>> {
let syntax = detect_comment_syntax(path);
let scar_body = syntax.render_scar(kind, body);
let original = std::fs::read_to_string(path)
.with_context(|| format!("reading {} for scar insertion", path.display()))?;
let newline = if original.contains("\r\n") {
"\r\n"
} else {
"\n"
};
let lines: Vec<&str> = original.split_inclusive('\n').collect();
let line_count = lines.len();
let target = line_number.max(1);
let insert_at = target.saturating_sub(1).min(line_count);
let indent = inherited_indent(&lines, insert_at);
let scar_line = if indent.is_empty() {
scar_body.clone()
} else {
format!("{indent}{scar_body}")
};
if insert_at > 0 {
let prev_raw = lines[insert_at - 1];
let prev_trimmed = prev_raw.trim_end_matches('\n').trim_end_matches('\r');
if prev_trimmed.trim() == scar_body.trim() {
return Ok(None);
}
}
let mut out = String::with_capacity(original.len() + scar_line.len() + newline.len() * 2);
for line in &lines[..insert_at] {
out.push_str(line);
}
if insert_at > 0 && !lines[insert_at - 1].ends_with('\n') {
out.push_str(newline);
}
out.push_str(&scar_line);
out.push_str(newline);
for line in &lines[insert_at..] {
out.push_str(line);
}
write_preserving_mtime(path, out.as_bytes())
.with_context(|| format!("writing {} with scar inserted", path.display()))?;
Ok(Some(ScarInsert {
line_1indexed: insert_at + 1,
rendered: scar_line,
}))
}
fn write_preserving_mtime(path: &Path, content: &[u8]) -> std::io::Result<()> {
let pre_mtime = std::fs::metadata(path).and_then(|m| m.modified()).ok();
std::fs::write(path, content)?;
if let Some(mtime) = pre_mtime
&& let Ok(f) = std::fs::File::options().write(true).open(path)
{
let _ = f.set_times(std::fs::FileTimes::new().set_modified(mtime));
}
Ok(())
}
fn inherited_indent(lines: &[&str], insert_at: usize) -> String {
fn line_indent(line: &str) -> Option<String> {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
if trimmed.trim().is_empty() {
return None;
}
let indent: String = trimmed
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect();
Some(indent)
}
if let Some(target) = lines.get(insert_at)
&& let Some(ind) = line_indent(target)
{
return ind;
}
for line in lines.iter().skip(insert_at + 1) {
if let Some(ind) = line_indent(line) {
return ind;
}
}
for line in lines[..insert_at].iter().rev() {
if let Some(ind) = line_indent(line) {
return ind;
}
}
String::new()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScarRemove {
Removed,
Mismatch,
OutOfRange,
}
pub fn remove_scar(path: &Path, line_1indexed: usize, expected: &str) -> Result<ScarRemove> {
let original = std::fs::read_to_string(path)
.with_context(|| format!("reading {} for scar removal", path.display()))?;
let lines: Vec<&str> = original.split_inclusive('\n').collect();
if line_1indexed == 0 || line_1indexed > lines.len() {
return Ok(ScarRemove::OutOfRange);
}
let target_idx = line_1indexed - 1;
let line_raw = lines[target_idx];
let line_trimmed = line_raw.trim_end_matches('\n').trim_end_matches('\r');
if line_trimmed.trim() != expected.trim() {
return Ok(ScarRemove::Mismatch);
}
let mut out = String::with_capacity(original.len().saturating_sub(line_raw.len()));
for (idx, line) in lines.iter().enumerate() {
if idx == target_idx {
continue;
}
out.push_str(line);
}
write_preserving_mtime(path, out.as_bytes())
.with_context(|| format!("writing {} with scar removed", path.display()))?;
Ok(ScarRemove::Removed)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::time::SystemTime;
fn syntax(path: &str) -> CommentSyntax {
detect_comment_syntax(&PathBuf::from(path))
}
#[test]
fn detect_slash_slash_for_c_family_and_web_script_languages() {
for path in [
"src/main.rs",
"src/app.ts",
"src/widget.tsx",
"src/lib.js",
"src/lib.jsx",
"src/Foo.java",
"src/server.go",
"src/a.c",
"src/a.cpp",
"src/a.cc",
"src/a.h",
"src/a.hpp",
"src/Contact.swift",
"src/Foo.kt",
"build.kts",
"src/Foo.scala",
"src/main.dart",
] {
assert_eq!(
syntax(path),
SLASH_SLASH,
"{path} should use // line comments"
);
}
}
#[test]
fn detect_hash_for_script_and_data_languages() {
for path in [
"app/server.rb",
"scripts/run.py",
"scripts/run.sh",
"scripts/run.bash",
"scripts/run.zsh",
"scripts/run.fish",
"config/app.yaml",
"config/app.yml",
"config/app.toml",
"config/app.ini",
"config/app.conf",
"analysis.r",
"tool.pl",
"lib/mod.ex",
"lib/mod.exs",
] {
assert_eq!(syntax(path), HASH, "{path} should use # line comments");
}
}
#[test]
fn detect_html_style_for_markup_and_markdown() {
for path in [
"web/index.html",
"web/index.htm",
"data/tree.xml",
"assets/icon.svg",
"web/App.vue",
"README.md",
] {
assert_eq!(
syntax(path),
HTML,
"{path} should use <!-- --> block comments"
);
}
}
#[test]
fn detect_c_block_for_stylesheets() {
for path in [
"web/style.css",
"web/theme.scss",
"web/theme.sass",
"web/theme.less",
] {
assert_eq!(syntax(path), CSS, "{path} should use /* */ comments");
}
}
#[test]
fn detect_dash_dash_for_sql_lua_haskell_ada() {
for path in [
"db/migrate.sql",
"db/schema.sqlite",
"src/main.lua",
"src/Main.hs",
"src/proc.ada",
] {
assert_eq!(
syntax(path),
DASH_DASH,
"{path} should use -- line comments"
);
}
}
#[test]
fn unknown_extension_falls_back_to_hash() {
for path in [
"weird.zzz",
"data.bin",
"Dockerfile.template",
"unknown.q9q9q9",
] {
assert_eq!(syntax(path), HASH, "{path} should fall back to # comment");
}
}
#[test]
fn no_extension_falls_back_to_hash() {
for path in ["Makefile", "Dockerfile", "LICENSE", "README"] {
assert_eq!(
syntax(path),
HASH,
"{path} without extension should fall back to # comment"
);
}
}
#[test]
fn extension_matching_is_case_insensitive() {
assert_eq!(syntax("src/MAIN.RS"), SLASH_SLASH);
assert_eq!(syntax("config.YAML"), HASH);
assert_eq!(syntax("page.HTML"), HTML);
assert_eq!(syntax("theme.CSS"), CSS);
assert_eq!(syntax("migrate.SQL"), DASH_DASH);
}
#[test]
fn render_scar_line_comment_emits_kizu_bracket_tag() {
assert_eq!(
SLASH_SLASH.render_scar(ScarKind::Ask, "explain this change"),
"// @kizu[ask]: explain this change"
);
assert_eq!(
HASH.render_scar(ScarKind::Reject, "revert this change"),
"# @kizu[reject]: revert this change"
);
assert_eq!(
DASH_DASH.render_scar(ScarKind::Free, "why here?"),
"-- @kizu[free]: why here?"
);
}
#[test]
fn render_scar_block_comment_wraps_kizu_bracket_tag() {
assert_eq!(
HTML.render_scar(ScarKind::Ask, "explain"),
"<!-- @kizu[ask]: explain -->"
);
assert_eq!(
CSS.render_scar(ScarKind::Free, "explain"),
"/* @kizu[free]: explain */"
);
}
#[test]
fn render_scar_preserves_unicode_and_whitespace_inside_body() {
assert_eq!(
SLASH_SLASH.render_scar(ScarKind::Free, "日本語 with spaces and 記号!"),
"// @kizu[free]: 日本語 with spaces and 記号!"
);
}
#[test]
fn scar_kind_tag_is_stable_across_all_variants() {
assert_eq!(ScarKind::Ask.tag(), "ask");
assert_eq!(ScarKind::Reject.tag(), "reject");
assert_eq!(ScarKind::Free.tag(), "free");
}
use std::fs;
use tempfile::TempDir;
fn write_tmp(dir: &TempDir, name: &str, content: &str) -> PathBuf {
let path = dir.path().join(name);
fs::write(&path, content).expect("write tmp file");
path
}
#[test]
fn insert_scar_drops_comment_one_line_above_target_in_rust_file() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn main() {\n let x = 1;\n let y = 2;\n}\n",
);
insert_scar(&path, 3, ScarKind::Ask, "explain this change").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(
after,
"fn main() {\n let x = 1;\n // @kizu[ask]: explain this change\n let y = 2;\n}\n"
);
}
#[test]
fn insert_scar_uses_python_hash_syntax_for_py_file() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "app.py", "def main():\n return 1\n");
insert_scar(&path, 2, ScarKind::Free, "why?").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(
after,
"def main():\n # @kizu[free]: why?\n return 1\n"
);
}
#[test]
fn insert_scar_uses_html_block_syntax_for_html_file() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "page.html", "<div>\n <p>hi</p>\n</div>\n");
insert_scar(&path, 2, ScarKind::Free, "check layout").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(
after,
"<div>\n <!-- @kizu[free]: check layout -->\n <p>hi</p>\n</div>\n"
);
}
#[test]
fn insert_scar_preserves_crlf_line_endings() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\r\nfn b() {}\r\n");
insert_scar(&path, 2, ScarKind::Free, "look").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {}\r\n// @kizu[free]: look\r\nfn b() {}\r\n");
}
#[test]
fn insert_scar_preserves_lf_line_endings_when_no_crlf_present() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\nfn b() {}\n");
insert_scar(&path, 2, ScarKind::Free, "look").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {}\n// @kizu[free]: look\nfn b() {}\n");
}
#[test]
fn insert_scar_is_idempotent_when_same_scar_is_already_above_target() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn a() {}\n// @kizu[free]: look\nfn b() {}\n",
);
insert_scar(&path, 3, ScarKind::Free, "look").expect("second insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {}\n// @kizu[free]: look\nfn b() {}\n");
}
#[test]
fn insert_scar_with_line_number_1_prepends_to_file_start() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\nfn b() {}\n");
insert_scar(&path, 1, ScarKind::Free, "root").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "// @kizu[free]: root\nfn a() {}\nfn b() {}\n");
}
#[test]
fn insert_scar_clamps_line_number_past_file_end_to_file_end() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\nfn b() {}\n");
insert_scar(&path, 999, ScarKind::Free, "tail").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {}\nfn b() {}\n// @kizu[free]: tail\n");
}
#[test]
fn insert_scar_errors_gracefully_on_missing_file() {
let dir = TempDir::new().expect("tmp");
let ghost = dir.path().join("nope.rs");
let err = insert_scar(&ghost, 1, ScarKind::Free, "x").expect_err("missing file");
let message = format!("{err:#}");
assert!(
message.contains("reading"),
"error should mention the read phase, got: {message}"
);
}
#[test]
fn insert_scar_at_eof_without_trailing_newline_adds_separator() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}");
insert_scar(&path, 999, ScarKind::Ask, "eof").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {}\n// @kizu[ask]: eof\n");
}
#[test]
fn insert_scar_inherits_leading_whitespace_of_target_line() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn main() {\n let x = 1;\n let y = 2;\n}\n",
);
insert_scar(&path, 2, ScarKind::Ask, "why one?")
.expect("insert")
.expect("receipt");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(
after,
"fn main() {\n // @kizu[ask]: why one?\n let x = 1;\n let y = 2;\n}\n"
);
}
#[test]
fn insert_scar_inherits_tab_indent() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.go", "func main() {\n\tfmt.Println()\n}\n");
insert_scar(&path, 2, ScarKind::Free, "n")
.expect("insert")
.expect("receipt");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(
after,
"func main() {\n\t// @kizu[free]: n\n\tfmt.Println()\n}\n"
);
}
#[test]
fn insert_scar_skips_blank_target_and_uses_nearest_non_blank_indent() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn main() {\n\n let x = 1;\n}\n");
insert_scar(&path, 2, ScarKind::Ask, "up")
.expect("insert")
.expect("receipt");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(
after,
"fn main() {\n // @kizu[ask]: up\n\n let x = 1;\n}\n"
);
}
#[test]
fn remove_scar_tolerates_indented_scar_line() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {\n let x = 1;\n}\n");
let receipt = insert_scar(&path, 2, ScarKind::Ask, "why?")
.expect("insert")
.expect("receipt");
let outcome = remove_scar(&path, receipt.line_1indexed, &receipt.rendered).expect("remove");
assert_eq!(outcome, ScarRemove::Removed);
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {\n let x = 1;\n}\n");
}
#[test]
fn insert_scar_respects_unknown_extension_by_falling_back_to_hash() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "notes.zzz", "first line\nsecond line\n");
insert_scar(&path, 2, ScarKind::Free, "n").expect("insert");
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "first line\n# @kizu[free]: n\nsecond line\n");
}
#[test]
fn insert_scar_returns_receipt_with_post_insert_line_number() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\nfn b() {}\n");
let receipt = insert_scar(&path, 2, ScarKind::Ask, "look")
.expect("insert")
.expect("receipt on actual write");
assert_eq!(receipt.line_1indexed, 2);
assert_eq!(receipt.rendered, "// @kizu[ask]: look");
}
#[test]
fn insert_scar_returns_none_when_idempotent_noop() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn a() {}\n// @kizu[free]: look\nfn b() {}\n",
);
let outcome = insert_scar(&path, 3, ScarKind::Free, "look").expect("second insert");
assert!(outcome.is_none(), "idempotent path should report no-op");
}
#[test]
fn remove_scar_deletes_line_when_expected_matches() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn a() {}\n// @kizu[ask]: look\nfn b() {}\n",
);
let outcome = remove_scar(&path, 2, "// @kizu[ask]: look").expect("remove");
assert_eq!(outcome, ScarRemove::Removed);
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {}\nfn b() {}\n");
}
#[test]
fn remove_scar_refuses_when_user_edited_the_line() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn a() {}\n// @kizu[ask]: edited by user\nfn b() {}\n",
);
let outcome = remove_scar(&path, 2, "// @kizu[ask]: look").expect("remove");
assert_eq!(outcome, ScarRemove::Mismatch);
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(
after,
"fn a() {}\n// @kizu[ask]: edited by user\nfn b() {}\n"
);
}
#[test]
fn remove_scar_reports_out_of_range_when_file_was_truncated() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\n");
let outcome = remove_scar(&path, 5, "anything").expect("remove");
assert_eq!(outcome, ScarRemove::OutOfRange);
}
#[test]
fn remove_scar_preserves_crlf_endings_of_surrounding_lines() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn a() {}\r\n// @kizu[free]: look\r\nfn b() {}\r\n",
);
let outcome = remove_scar(&path, 2, "// @kizu[free]: look").expect("remove");
assert_eq!(outcome, ScarRemove::Removed);
let after = fs::read_to_string(&path).expect("read back");
assert_eq!(after, "fn a() {}\r\nfn b() {}\r\n");
}
#[test]
fn insert_then_remove_using_receipt_round_trips() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\nfn b() {}\n");
let before = fs::read_to_string(&path).expect("read before");
let receipt = insert_scar(&path, 2, ScarKind::Ask, "look")
.expect("insert")
.expect("receipt");
let outcome = remove_scar(&path, receipt.line_1indexed, &receipt.rendered).expect("remove");
assert_eq!(outcome, ScarRemove::Removed);
let after = fs::read_to_string(&path).expect("read after");
assert_eq!(after, before);
}
#[test]
fn insert_scar_preserves_file_mtime() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(&dir, "main.rs", "fn a() {}\nfn b() {}\n");
let pre = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
let f = fs::File::options()
.write(true)
.open(&path)
.expect("open for set_times");
f.set_times(fs::FileTimes::new().set_modified(pre))
.expect("set pre mtime");
drop(f);
insert_scar(&path, 2, ScarKind::Ask, "look")
.expect("insert")
.expect("receipt");
let post = fs::metadata(&path)
.expect("metadata")
.modified()
.expect("mtime");
assert_eq!(
post, pre,
"scar insert should preserve the file's modified time"
);
}
#[test]
fn remove_scar_preserves_file_mtime() {
let dir = TempDir::new().expect("tmp");
let path = write_tmp(
&dir,
"main.rs",
"fn a() {}\n// @kizu[ask]: look\nfn b() {}\n",
);
let pre = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
let f = fs::File::options()
.write(true)
.open(&path)
.expect("open for set_times");
f.set_times(fs::FileTimes::new().set_modified(pre))
.expect("set pre mtime");
drop(f);
let outcome = remove_scar(&path, 2, "// @kizu[ask]: look").expect("remove");
assert_eq!(outcome, ScarRemove::Removed);
let post = fs::metadata(&path)
.expect("metadata")
.modified()
.expect("mtime");
assert_eq!(
post, pre,
"scar remove should preserve the file's modified time"
);
}
}