use chrono::Utc;
use clap::Args as ClapArgs;
use std::io::{self, BufRead};
use std::path::Path;
use crate::annotation::{self, Annotation, AnnotationBody, IssuerType, Kind, Record};
use crate::content_hash;
use crate::qual_file;
#[derive(ClapArgs)]
pub struct Args {
pub kind: Option<String>,
pub location: Option<String>,
pub message: Option<String>,
#[arg(long)]
pub detail: Option<String>,
#[arg(long, alias = "fix")]
pub suggested_fix: Option<String>,
#[arg(long = "tag")]
pub tags: Vec<String>,
#[arg(long)]
pub issuer: Option<String>,
#[arg(long)]
pub issuer_type: Option<String>,
#[arg(long, name = "ref")]
pub r#ref: Option<String>,
#[arg(long)]
pub span: Option<String>,
#[arg(long)]
pub supersedes: Option<String>,
#[arg(long)]
pub references: Option<String>,
#[arg(long)]
pub file: Option<String>,
#[arg(long)]
pub stdin: bool,
#[arg(long)]
pub continue_on_error: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long, default_value = "human")]
pub format: String,
}
pub fn run(args: Args) -> crate::Result<()> {
if args.stdin {
return run_batch(&args.format, args.continue_on_error, args.dry_run);
}
let kind_str = args.kind.as_deref().ok_or_else(|| {
crate::Error::Validation("<kind> is required (or use --stdin for batch mode)".into())
})?;
let kind: Kind = kind_str.parse().unwrap();
let location = args.location.as_deref().ok_or_else(|| {
crate::Error::Validation("<location> is required (or use --stdin for batch mode)".into())
})?;
let message = args.message.ok_or_else(|| {
crate::Error::Validation("<message> is required (or use --stdin for batch mode)".into())
})?;
let (subject, location_span) = annotation::parse_location(location);
let mut span = match &args.span {
Some(s) => Some(annotation::parse_span(s).map_err(crate::Error::Validation)?),
None => location_span,
};
if let Some(ref mut s) = span
&& let Some(hash) = content_hash::compute_span_hash(Path::new(&subject), s)
{
s.content_hash = Some(hash);
}
let issuer = normalize_issuer_uri(
args.issuer
.or_else(detect_issuer)
.unwrap_or_else(|| "mailto:unknown@localhost".into()),
);
let issuer_type = match &args.issuer_type {
Some(s) => Some(s.parse::<IssuerType>().map_err(crate::Error::Validation)?),
None => None,
};
let qual_path = qual_file::resolve_qual_path(&subject, args.file.as_deref().map(Path::new))?;
let att = annotation::finalize(Annotation {
metabox: "1".into(),
record_type: "annotation".into(),
subject,
issuer,
issuer_type,
created_at: Utc::now(),
id: String::new(),
body: AnnotationBody {
detail: args.detail,
kind,
r#ref: args.r#ref,
references: args.references,
span,
suggested_fix: args.suggested_fix,
summary: message,
supersedes: args.supersedes.clone(),
tags: args.tags,
},
});
let errors = annotation::validate(&att);
if !errors.is_empty() {
return Err(crate::Error::Validation(errors.join("; ")));
}
if att.body.supersedes.is_some() {
let existing = if qual_path.exists() {
qual_file::parse(&qual_path)?.records
} else {
Vec::new()
};
let mut all = existing;
all.push(Record::Annotation(Box::new(att.clone())));
annotation::check_supersession_cycles(&all)?;
annotation::validate_supersession_targets(&all)?;
}
let record = Record::Annotation(Box::new(att.clone()));
qual_file::append(qual_path.as_ref(), &record)?;
if args.format == "json" {
println!("{}", serde_json::to_string(&record)?);
} else {
let span_str = match &att.body.span {
Some(span) => {
let end = match &span.end {
Some(e) if e.line != span.start.line => format!(":{}", e.line),
_ => String::new(),
};
format!(":{}{}", span.start.line, end)
}
None => String::new(),
};
println!(
"{} {}{} {}",
att.body.kind, att.subject, span_str, att.body.summary,
);
println!(" id: {}", att.id);
}
Ok(())
}
fn run_batch(format: &str, continue_on_error: bool, dry_run: bool) -> crate::Result<()> {
let stdin = io::stdin();
let mut recorded = 0usize;
let mut errors: Vec<BatchError> = Vec::new();
for (line_idx, line) in stdin.lock().lines().enumerate() {
let line_no = line_idx + 1;
let raw = match line {
Ok(l) => l,
Err(e) => {
let be = BatchError {
line: line_no,
error: format!("io error: {e}"),
input: String::new(),
};
if continue_on_error {
emit_batch_error(&be, format);
errors.push(be);
continue;
}
return Err(be.into_error());
}
};
let trimmed = raw.trim();
if trimmed.is_empty() || trimmed.starts_with("//") {
continue;
}
match process_one(trimmed, dry_run) {
Ok(record) => {
emit_batch_line(&record, format, dry_run)?;
recorded += 1;
}
Err(msg) => {
let be = BatchError {
line: line_no,
error: msg,
input: trimmed.to_string(),
};
if continue_on_error {
emit_batch_error(&be, format);
errors.push(be);
continue;
}
return Err(be.into_error());
}
}
}
let total = recorded + errors.len();
let suffix = if dry_run {
" (dry run, nothing written)"
} else {
""
};
if format == "json" {
let summary = serde_json::json!({
"summary": {
"recorded": recorded,
"failed": errors.len(),
"total": total,
"dry_run": dry_run,
}
});
eprintln!("{summary}");
} else {
eprintln!(
"Recorded {recorded} of {total} records from stdin{}{}",
if errors.is_empty() {
String::new()
} else {
format!(", {} failed", errors.len())
},
suffix
);
}
if !errors.is_empty() {
if format == "json" {
std::process::exit(1);
}
return Err(crate::Error::Validation(format!(
"{} of {} stdin records failed (--continue-on-error)",
errors.len(),
total
)));
}
Ok(())
}
fn process_one(trimmed: &str, dry_run: bool) -> std::result::Result<Record, String> {
let value: serde_json::Value =
serde_json::from_str(trimmed).map_err(|e| format!("invalid JSON: {e}"))?;
let record = if value.get("body").is_some() && value.get("subject").is_some() {
let r: Record =
serde_json::from_value(value).map_err(|e| format!("invalid record: {e}"))?;
annotation::finalize_record(r)
} else {
build_record_from_overrides(value).map_err(|e| e.to_string())?
};
if let Some(att) = record.as_annotation() {
let errors = annotation::validate(att);
if !errors.is_empty() {
return Err(errors.join("; "));
}
}
let qual_path =
qual_file::resolve_qual_path(record.subject(), None).map_err(|e| e.to_string())?;
if record.supersedes().is_some() {
let existing = if qual_path.exists() {
qual_file::parse(&qual_path)
.map_err(|e| e.to_string())?
.records
} else {
Vec::new()
};
let mut all = existing;
all.push(record.clone());
annotation::check_supersession_cycles(&all).map_err(|e| e.to_string())?;
annotation::validate_supersession_targets(&all).map_err(|e| e.to_string())?;
}
if !dry_run {
qual_file::append(&qual_path, &record).map_err(|e| e.to_string())?;
}
Ok(record)
}
struct BatchError {
line: usize,
error: String,
input: String,
}
impl BatchError {
fn into_error(self) -> crate::Error {
let truncated = truncate_for_display(&self.input, 200);
crate::Error::Validation(if truncated.is_empty() {
format!("stdin line {}: {}", self.line, self.error)
} else {
format!("stdin line {}: {}: {}", self.line, self.error, truncated)
})
}
}
fn truncate_for_display(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
let prefix: String = s.chars().take(max).collect();
format!("{prefix}...")
}
}
fn emit_batch_error(be: &BatchError, format: &str) {
if format == "json" {
let v = serde_json::json!({
"line": be.line,
"error": be.error,
"input": be.input,
});
eprintln!("{v}");
} else {
let truncated = truncate_for_display(&be.input, 200);
if truncated.is_empty() {
eprintln!("stdin line {}: {}", be.line, be.error);
} else {
eprintln!("stdin line {}: {}: {}", be.line, be.error, truncated);
}
}
}
fn emit_batch_line(record: &Record, format: &str, dry_run: bool) -> crate::Result<()> {
if format == "json" {
let mut v = serde_json::to_value(record)?;
if dry_run && let Some(obj) = v.as_object_mut() {
obj.insert("dry_run".into(), serde_json::Value::Bool(true));
}
println!("{}", serde_json::to_string(&v)?);
return Ok(());
}
let verb = if dry_run {
"would-record"
} else {
"recorded "
};
let id = record.id();
let id_short = if id.len() >= 8 { &id[..8] } else { id };
if let Some(att) = record.as_annotation() {
let span_str = match &att.body.span {
Some(span) => {
let end = match &span.end {
Some(e) if e.line != span.start.line => format!(":{}", e.line),
_ => String::new(),
};
format!(":{}{}", span.start.line, end)
}
None => String::new(),
};
println!(
"{verb} {:<10} {}{} {} id: {}",
att.body.kind.to_string(),
att.subject,
span_str,
att.body.summary,
id_short,
);
} else {
println!(
"{verb} {:<10} {} id: {}",
record.record_type(),
record.subject(),
id_short,
);
}
Ok(())
}
fn build_record_from_overrides(value: serde_json::Value) -> crate::Result<Record> {
let obj = value
.as_object()
.ok_or_else(|| crate::Error::Validation("stdin line must be a JSON object".into()))?;
let kind_str = obj
.get("kind")
.and_then(|v| v.as_str())
.ok_or_else(|| crate::Error::Validation("stdin object missing 'kind'".into()))?;
let kind: Kind = kind_str.parse().unwrap();
let location = obj
.get("location")
.and_then(|v| v.as_str())
.ok_or_else(|| crate::Error::Validation("stdin object missing 'location'".into()))?;
let message = obj
.get("message")
.and_then(|v| v.as_str())
.ok_or_else(|| crate::Error::Validation("stdin object missing 'message'".into()))?;
let (subject, location_span) = annotation::parse_location(location);
let mut span = match obj.get("span").and_then(|v| v.as_str()) {
Some(s) => Some(annotation::parse_span(s).map_err(crate::Error::Validation)?),
None => location_span,
};
if let Some(ref mut s) = span
&& let Some(hash) = content_hash::compute_span_hash(Path::new(&subject), s)
{
s.content_hash = Some(hash);
}
let issuer = normalize_issuer_uri(
obj.get("issuer")
.and_then(|v| v.as_str())
.map(String::from)
.or_else(detect_issuer)
.unwrap_or_else(|| "mailto:unknown@localhost".into()),
);
let issuer_type = match obj.get("issuer_type").and_then(|v| v.as_str()) {
Some(s) => Some(s.parse::<IssuerType>().map_err(crate::Error::Validation)?),
None => None,
};
let detail = obj.get("detail").and_then(|v| v.as_str()).map(String::from);
let suggested_fix = obj
.get("suggested_fix")
.and_then(|v| v.as_str())
.map(String::from);
let r#ref = obj.get("ref").and_then(|v| v.as_str()).map(String::from);
let supersedes = obj
.get("supersedes")
.and_then(|v| v.as_str())
.map(String::from);
let references = obj
.get("references")
.and_then(|v| v.as_str())
.map(String::from);
let tags: Vec<String> = obj
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|t| t.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let att = annotation::finalize(Annotation {
metabox: "1".into(),
record_type: "annotation".into(),
subject,
issuer,
issuer_type,
created_at: Utc::now(),
id: String::new(),
body: AnnotationBody {
detail,
kind,
r#ref,
references,
span,
suggested_fix,
summary: message.to_string(),
supersedes,
tags,
},
});
Ok(Record::Annotation(Box::new(att)))
}
pub fn detect_issuer() -> Option<String> {
std::process::Command::new("git")
.args(["config", "user.email"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.map(|email| format!("mailto:{email}"))
.or_else(|| {
std::process::Command::new("hg")
.args(["config", "ui.username"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.filter(|s| !s.is_empty())
.map(|email| format!("mailto:{email}"))
})
.or_else(|| {
let user = std::env::var("USER").unwrap_or_else(|_| "unknown".into());
Some(format!("mailto:{user}@localhost"))
})
}
pub fn normalize_issuer_uri(issuer: String) -> String {
if issuer.contains(':') {
issuer
} else {
format!("mailto:{issuer}")
}
}