use std::{collections::HashMap, path::Path};
use anyhow::{Result, anyhow};
use objects::{
object::{
AnnotationStatus, Attribution, ChangeId, ContentHash, ContextTarget, FileProvenance,
ProvenanceError, Tree,
},
store::ObjectStore,
};
use repo::Repository;
use serde::Serialize;
use super::{
advice::RecoveryAdvice,
history_target::{require_resolved_state, resolve_state_id},
snapshot::ensure_current_state,
};
use crate::{
cli::{Cli, should_output_json},
config::UserConfig,
};
#[derive(Clone, Serialize)]
struct PrincipalInfo {
name: String,
email: String,
}
#[derive(Clone, Serialize)]
struct AgentInfo {
provider: String,
model: String,
#[serde(skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
policy_id: Option<String>,
}
fn attribution_parts(attribution: &Attribution) -> (PrincipalInfo, Option<AgentInfo>) {
let principal = PrincipalInfo {
name: attribution.principal.name.clone(),
email: attribution.principal.email.clone(),
};
let agent = attribution.agent.as_ref().map(|a| AgentInfo {
provider: a.provider.clone(),
model: a.model.clone(),
session_id: a.session_id.clone(),
policy_id: a.policy_id.clone(),
});
(principal, agent)
}
#[derive(Serialize)]
struct BlameLine {
line_number: usize,
content: String,
change_id: String,
principal: PrincipalInfo,
agent: Option<AgentInfo>,
timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
origins: Option<Vec<BlameOrigin>>,
}
#[derive(Serialize)]
struct BlameOutput {
output_kind: &'static str,
status: &'static str,
file: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
context: Vec<ContextSnippet>,
lines: Vec<BlameLine>,
}
#[derive(Clone, Serialize)]
struct BlameOrigin {
change_id: String,
principal: PrincipalInfo,
agent: Option<AgentInfo>,
timestamp: String,
}
#[derive(Clone, Serialize)]
struct ContextSnippet {
annotation_id: String,
kind: String,
content: String,
revision_count: usize,
}
#[derive(Clone)]
struct LineInfo {
change_id: ChangeId,
attribution: Attribution,
extra_origins: usize,
timestamp: String,
origins: Vec<BlameOrigin>,
}
impl LineInfo {
fn author_display(&self) -> String {
if self.extra_origins == 0 {
self.attribution.to_string()
} else {
format!("{} +{}", self.attribution, self.extra_origins)
}
}
}
pub fn cmd_query_attribution(
cli: &Cli,
file: String,
state: Option<String>,
show_context: bool,
) -> Result<()> {
cmd_blame_with_output_kind(cli, file, state, show_context, "query_attribution")
}
fn cmd_blame_with_output_kind(
cli: &Cli,
file: String,
state: Option<String>,
show_context: bool,
output_kind: &'static str,
) -> Result<()> {
let repo = cli.open_repo()?;
let target_state_id = if let Some(state_id) = state {
if matches!(state_id.as_str(), "HEAD" | "@") && repo.current_state()?.is_none() {
ensure_current_state(
&repo,
&UserConfig::load_default().unwrap_or_default(),
Some(format!("Bootstrap git-overlay before blaming {}", file)),
)?;
}
resolve_state_id(&repo, &state_id)?
} else {
ensure_current_state(
&repo,
&UserConfig::load_default().unwrap_or_default(),
Some(format!("Bootstrap git-overlay before blaming {}", file)),
)?
};
let state_obj = require_resolved_state(&repo, &target_state_id)?;
let tree = repo
.store()
.get_tree(&state_obj.tree)?
.ok_or_else(|| anyhow!("Tree not found"))?;
let content_hash = find_file_in_tree(&repo, &tree, Path::new(&file))?;
let blob = repo
.store()
.get_blob(&content_hash)?
.ok_or_else(|| anyhow!("Blob not found"))?;
let content = String::from_utf8_lossy(blob.content());
let lines: Vec<&str> = content.lines().collect();
let provenance = repo
.get_file_provenance_for_state(&state_obj, Path::new(&file))?
.ok_or_else(|| {
anyhow!(
"No provenance data for '{}' in state {}",
file,
target_state_id
)
})?;
let line_infos = compute_blame_from_provenance(&provenance)?;
let context = if show_context {
collect_file_context(&repo, &state_obj, &file)?
} else {
Vec::new()
};
let state_display_ts = state_obj
.authored_at
.unwrap_or(state_obj.created_at)
.to_rfc3339();
if should_output_json(cli, Some(repo.config())) {
let output_lines: Vec<BlameLine> = lines
.iter()
.enumerate()
.map(|(i, line)| {
let info = line_infos.get(&i).cloned().unwrap_or_else(|| {
state_fallback_line_info(target_state_id, &state_obj, &state_display_ts)
});
let (principal, agent) = attribution_parts(&info.attribution);
BlameLine {
line_number: i + 1,
content: line.to_string(),
change_id: info.change_id.to_string(),
principal,
agent,
timestamp: info.timestamp,
origins: (!info.origins.is_empty()).then_some(info.origins),
}
})
.collect();
let output = BlameOutput {
output_kind,
status: "completed",
file: file.clone(),
context,
lines: output_lines,
};
println!("{}", serde_json::to_string(&output)?);
} else {
if show_context && !context.is_empty() {
println!("Applicable Context:");
println!("-------------------");
for annotation in &context {
println!(
" [{}] {} ({} rev{})",
annotation.kind,
annotation.content,
annotation.revision_count,
if annotation.revision_count == 1 {
""
} else {
"s"
}
);
}
println!();
}
for (i, line) in lines.iter().enumerate() {
let info = line_infos.get(&i).cloned().unwrap_or_else(|| {
state_fallback_line_info(target_state_id, &state_obj, &state_display_ts)
});
println!(
"{:12} {:20} {}",
info.change_id.short(),
fit_author(&info.author_display(), 20),
line
);
}
}
Ok(())
}
fn state_fallback_line_info(
state_id: ChangeId,
state: &objects::object::State,
display_ts: &str,
) -> LineInfo {
let (principal, agent) = attribution_parts(&state.attribution);
LineInfo {
change_id: state_id,
attribution: state.attribution.clone(),
extra_origins: 0,
timestamp: display_ts.to_string(),
origins: vec![BlameOrigin {
change_id: state_id.to_string(),
principal,
agent,
timestamp: display_ts.to_string(),
}],
}
}
fn collect_file_context(
repo: &Repository,
state: &objects::object::State,
file: &str,
) -> Result<Vec<ContextSnippet>> {
let Some(context_root) = &state.context else {
return Ok(Vec::new());
};
let target = ContextTarget::file(file.to_string())?;
let Some(blob) = repo.get_context_blob(context_root, &target)? else {
return Ok(Vec::new());
};
Ok(blob
.annotations
.iter()
.filter(|annotation| annotation.status == AnnotationStatus::Active)
.filter_map(|annotation| {
annotation
.current_revision()
.map(|revision| ContextSnippet {
annotation_id: annotation.annotation_id.clone(),
kind: revision.kind.to_string(),
content: summarize_context(&revision.content),
revision_count: annotation.revisions.len(),
})
})
.collect())
}
fn summarize_context(content: &str) -> String {
let first_line = content
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("");
if first_line.len() <= 88 {
first_line.to_string()
} else {
format!("{}...", &first_line[..85])
}
}
fn find_file_in_tree(repo: &Repository, tree: &Tree, file: &Path) -> Result<ContentHash> {
let Some(name) = file.iter().next().and_then(|part| part.to_str()) else {
return Err(anyhow!(blame_file_not_found_advice(file)));
};
let entry = tree
.get(name)
.ok_or_else(|| anyhow!(blame_file_not_found_advice(file)))?;
let mut components = file.iter();
components.next();
let rest = components.as_path();
if rest.as_os_str().is_empty() {
return Ok(entry.hash);
}
if !entry.is_tree() {
return Err(anyhow!(blame_file_not_found_advice(file)));
}
let subtree = repo
.store()
.get_tree(&entry.hash)?
.ok_or_else(|| anyhow!(blame_file_not_found_advice(file)))?;
find_file_in_tree(repo, &subtree, rest)
}
fn blame_file_not_found_advice(file: &Path) -> RecoveryAdvice {
RecoveryAdvice::safety_refusal(
"blame_file_not_found",
format!("File '{}' not found in state", file.display()),
"Inspect the state with `heddle show`, then retry `heddle query --attribution <path>` with a tracked file.",
format!(
"requested blame path '{}' does not exist in the selected Heddle state",
file.display()
),
"blame cannot attribute lines for a path that is absent from the selected state",
"repository state, refs, and worktree files were left unchanged",
"heddle show",
vec!["heddle show".to_string()],
)
}
fn compute_blame_from_provenance(provenance: &FileProvenance) -> Result<HashMap<usize, LineInfo>> {
provenance
.validate()
.map_err(|error: ProvenanceError| anyhow!(error.to_string()))?;
let line_sets = provenance
.line_origin_set_indexes()
.map_err(|error: ProvenanceError| anyhow!(error.to_string()))?;
let mut infos = HashMap::new();
for (index, set_index) in line_sets.into_iter().enumerate() {
let origin_set = provenance
.origin_sets
.get(set_index as usize)
.ok_or_else(|| anyhow!("invalid provenance origin set"))?;
let origins: Vec<BlameOrigin> = origin_set
.origin_indexes
.iter()
.map(|origin_index| {
let origin = &provenance.origins[*origin_index as usize];
let (principal, agent) = attribution_parts(&origin.attribution);
BlameOrigin {
change_id: origin.state_id.to_string(),
principal,
agent,
timestamp: origin.authored_at.unwrap_or(origin.created_at).to_rfc3339(),
}
})
.collect();
let primary = provenance
.origins
.get(origin_set.origin_indexes[0] as usize)
.ok_or_else(|| anyhow!("invalid provenance origin index"))?;
infos.insert(
index,
LineInfo {
change_id: primary.state_id,
attribution: primary.attribution.clone(),
extra_origins: origins.len().saturating_sub(1),
timestamp: primary
.authored_at
.unwrap_or(primary.created_at)
.to_rfc3339(),
origins,
},
);
}
Ok(infos)
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len.saturating_sub(3)])
}
}
fn fit_author(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
return s.to_string();
}
if let Some(angle) = s.find(" <") {
let name = &s[..angle];
if name.len() <= max_len {
return name.to_string();
}
}
truncate(s, max_len)
}
#[cfg(test)]
mod tests {
use objects::object::{Agent, Attribution, Principal, State};
use super::*;
fn human() -> Attribution {
Attribution::human(Principal::new("Ada Lovelace", "ada@example.com"))
}
fn agentic() -> Attribution {
Attribution::with_agent(
Principal::new("Ada Lovelace", "ada@example.com"),
Agent::new("anthropic", "claude-opus-4-7")
.with_session("sess-1", "seg-1")
.with_policy("pol-1"),
)
}
#[test]
fn attribution_parts_splits_principal_and_agent() {
let (principal, agent) = attribution_parts(&agentic());
assert_eq!(principal.name, "Ada Lovelace");
assert_eq!(principal.email, "ada@example.com");
let agent = agent.expect("agent attribution should be structured");
assert_eq!(agent.provider, "anthropic");
assert_eq!(agent.model, "claude-opus-4-7");
assert_eq!(agent.session_id.as_deref(), Some("sess-1"));
assert_eq!(agent.policy_id.as_deref(), Some("pol-1"));
}
#[test]
fn attribution_parts_omits_agent_for_human_only() {
let (principal, agent) = attribution_parts(&human());
assert_eq!(principal.name, "Ada Lovelace");
assert!(
agent.is_none(),
"human-only attribution must not synthesize an agent"
);
}
#[test]
fn agent_info_skips_none_session_and_policy() {
let (_, agent) = attribution_parts(&Attribution::with_agent(
Principal::new("Ada", "ada@example.com"),
Agent::new("openai", "gpt-5"),
));
let json = serde_json::to_value(agent.unwrap()).unwrap();
assert_eq!(json["provider"], "openai");
assert_eq!(json["model"], "gpt-5");
assert!(
json.get("session_id").is_none(),
"absent session_id must be omitted, not null"
);
assert!(
json.get("policy_id").is_none(),
"absent policy_id must be omitted, not null"
);
}
#[test]
fn author_display_appends_extra_origin_count() {
let info = LineInfo {
change_id: ChangeId::generate(),
attribution: human(),
extra_origins: 2,
timestamp: "2026-01-01T00:00:00+00:00".to_string(),
origins: Vec::new(),
};
assert_eq!(info.author_display(), "Ada Lovelace <ada@example.com> +2");
}
#[test]
fn author_display_single_origin_has_no_suffix() {
let info = LineInfo {
change_id: ChangeId::generate(),
attribution: human(),
extra_origins: 0,
timestamp: "2026-01-01T00:00:00+00:00".to_string(),
origins: Vec::new(),
};
assert_eq!(info.author_display(), "Ada Lovelace <ada@example.com>");
}
#[test]
fn state_fallback_line_info_attributes_whole_line_to_state() {
let state_id = ChangeId::generate();
let state = State::new(ContentHash::from_bytes([7u8; 32]), vec![], agentic());
let ts = "2026-02-03T04:05:06+00:00";
let info = state_fallback_line_info(state_id, &state, ts);
assert_eq!(info.change_id, state_id);
assert_eq!(info.extra_origins, 0);
assert_eq!(info.timestamp, ts);
assert_eq!(info.attribution, state.attribution);
assert_eq!(info.origins.len(), 1);
let origin = &info.origins[0];
assert_eq!(origin.change_id, state_id.to_string());
assert_eq!(origin.timestamp, ts);
assert_eq!(origin.principal.name, "Ada Lovelace");
assert_eq!(
origin.agent.as_ref().map(|a| a.provider.as_str()),
Some("anthropic")
);
}
}