use std::collections::HashSet;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::model::{Issue, IssueType};
use crate::storage::sqlite::SqliteStorage;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct InheritedBlock {
pub source_id: String,
pub source_role: String,
pub source_title: String,
pub field_used: String,
pub content: String,
}
#[must_use]
pub fn is_enabled(beads_dir: &Path) -> bool {
let env_val = std::env::var("BR_INHERITED_CONTEXT").ok();
is_enabled_from_inputs(env_val.as_deref(), &beads_dir.join("config.yaml"))
}
#[must_use]
pub fn is_enabled_from_inputs(env_val: Option<&str>, config_yaml_path: &Path) -> bool {
if let Some(env_val) = env_val {
let trimmed = env_val.trim();
return !trimmed.is_empty()
&& !trimmed.eq_ignore_ascii_case("0")
&& !trimmed.eq_ignore_ascii_case("false")
&& !trimmed.eq_ignore_ascii_case("no");
}
let Ok(contents) = std::fs::read_to_string(config_yaml_path) else {
return false;
};
let Ok(value): std::result::Result<serde_yml::Value, _> = serde_yml::from_str(&contents) else {
return false;
};
value
.get("inherited_context")
.and_then(|node| node.get("enabled"))
.and_then(serde_yml::Value::as_bool)
.unwrap_or(false)
}
pub fn collect_inherited_blocks(
storage: &SqliteStorage,
child_id: &str,
) -> Result<Vec<InheritedBlock>> {
let Some(immediate_parent_id) = find_immediate_parent_id(storage, child_id)? else {
return Ok(Vec::new());
};
let mut visited: HashSet<String> = HashSet::new();
visited.insert(child_id.to_string());
let Some(immediate_parent) = load_active_issue(storage, &immediate_parent_id)? else {
return Ok(Vec::new());
};
visited.insert(immediate_parent.id.clone());
let root = walk_to_root(storage, &immediate_parent, &mut visited)?;
let mut blocks = Vec::new();
let mut emitted_ids: HashSet<String> = HashSet::new();
if let Some(root_issue) = root.as_ref() {
if !emitted_ids.contains(&root_issue.id)
&& root_issue.id != immediate_parent.id
&& let Some(block) = block_from_ancestor(root_issue, root_role(root_issue))
{
emitted_ids.insert(root_issue.id.clone());
blocks.push(block);
}
}
let parent_is_also_root = root.as_ref().is_some_and(|r| r.id == immediate_parent.id);
let parent_role =
if parent_is_also_root && matches!(immediate_parent.issue_type, IssueType::Epic) {
"epic"
} else {
"parent"
};
if !emitted_ids.contains(&immediate_parent.id)
&& let Some(block) = block_from_ancestor(&immediate_parent, parent_role)
{
emitted_ids.insert(immediate_parent.id.clone());
blocks.push(block);
}
Ok(blocks)
}
fn root_role(issue: &Issue) -> &'static str {
if matches!(issue.issue_type, IssueType::Epic) {
"epic"
} else {
"root"
}
}
fn block_from_ancestor(issue: &Issue, role: &str) -> Option<InheritedBlock> {
let content = issue.agent_context.as_deref()?.trim();
if content.is_empty() {
return None;
}
Some(InheritedBlock {
source_id: issue.id.clone(),
source_role: role.to_string(),
source_title: issue.title.clone(),
field_used: "agent_context".to_string(),
content: content.to_string(),
})
}
fn find_immediate_parent_id(storage: &SqliteStorage, child_id: &str) -> Result<Option<String>> {
let deps = storage.get_dependencies_full(child_id)?;
Ok(deps
.into_iter()
.find(|d| matches!(d.dep_type, crate::model::DependencyType::ParentChild))
.map(|d| d.depends_on_id))
}
fn walk_to_root(
storage: &SqliteStorage,
start: &Issue,
visited: &mut HashSet<String>,
) -> Result<Option<Issue>> {
let mut current = start.clone();
let mut latest_epic: Option<Issue> = if matches!(current.issue_type, IssueType::Epic) {
Some(current.clone())
} else {
None
};
loop {
let Some(parent_id) = find_immediate_parent_id(storage, ¤t.id)? else {
break;
};
if !visited.insert(parent_id.clone()) {
eprintln!(
"br: inheritance: detected cycle in parent chain at {parent_id}; \
stopping ancestor traversal"
);
break;
}
let Some(parent) = load_active_issue(storage, &parent_id)? else {
break;
};
if matches!(parent.issue_type, IssueType::Epic) {
latest_epic = Some(parent.clone());
}
current = parent;
}
Ok(latest_epic.or(Some(current)))
}
fn load_active_issue(storage: &SqliteStorage, id: &str) -> Result<Option<Issue>> {
let issue = storage.get_issue(id)?;
let Some(issue) = issue else { return Ok(None) };
if matches!(issue.status, crate::model::Status::Tombstone) || issue.deleted_at.is_some() {
return Ok(None);
}
Ok(Some(issue))
}
#[must_use]
pub fn render_text(blocks: &[InheritedBlock]) -> String {
if blocks.is_empty() {
return String::new();
}
let mut out = String::new();
for block in blocks {
let role = match block.source_role.as_str() {
"epic" => format!("epic {}", block.source_id),
"root" => format!("root ancestor {}", block.source_id),
"parent" => format!("parent {}", block.source_id),
other => format!("{other} {}", block.source_id),
};
out.push_str(&format!(
"--- Inherited context (from {role}: {title:?}) ---\n",
title = block.source_title
));
let pretty = serde_json::from_str::<serde_json::Value>(&block.content)
.ok()
.and_then(|v| serde_json::to_string_pretty(&v).ok())
.unwrap_or_else(|| block.content.clone());
out.push_str(&pretty);
if !pretty.ends_with('\n') {
out.push('\n');
}
out.push_str(&format!(
"--- End inherited context ({}) ---\n",
block.source_id
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_text_emits_empty_string_for_no_blocks() {
assert_eq!(render_text(&[]), "");
}
#[test]
fn render_text_emits_blocks_in_order_with_role_labels() {
let blocks = vec![
InheritedBlock {
source_id: "bd-epic".into(),
source_role: "epic".into(),
source_title: "Auth rewrite".into(),
field_used: "agent_context".into(),
content: "{\"skills\":[\"clean-code\"]}".into(),
},
InheritedBlock {
source_id: "bd-parent".into(),
source_role: "parent".into(),
source_title: "Token refresh".into(),
field_used: "agent_context".into(),
content: "{\"constraints\":[\"backwards-compatible\"]}".into(),
},
];
let out = render_text(&blocks);
let epic_idx = out.find("epic bd-epic").expect("epic label present");
let parent_idx = out.find("parent bd-parent").expect("parent label present");
assert!(epic_idx < parent_idx, "root/epic must precede parent");
assert!(out.contains("Auth rewrite"));
assert!(out.contains("clean-code"));
assert!(out.contains("backwards-compatible"));
}
#[test]
fn render_text_falls_back_to_raw_on_malformed_json() {
let blocks = vec![InheritedBlock {
source_id: "bd-x".into(),
source_role: "parent".into(),
source_title: "Title".into(),
field_used: "agent_context".into(),
content: "not-json-but-still-shown".into(),
}];
let out = render_text(&blocks);
assert!(out.contains("not-json-but-still-shown"));
}
#[test]
fn is_enabled_respects_env_var_truthy_values() {
let missing_config = std::path::Path::new("/nonexistent/config.yaml");
assert!(is_enabled_from_inputs(Some("1"), missing_config));
assert!(is_enabled_from_inputs(Some("true"), missing_config));
assert!(is_enabled_from_inputs(Some("TRUE"), missing_config));
assert!(is_enabled_from_inputs(Some("yes"), missing_config));
assert!(is_enabled_from_inputs(
Some("anything-non-empty"),
missing_config
));
assert!(!is_enabled_from_inputs(Some("0"), missing_config));
assert!(!is_enabled_from_inputs(Some("false"), missing_config));
assert!(!is_enabled_from_inputs(Some("FALSE"), missing_config));
assert!(!is_enabled_from_inputs(Some("no"), missing_config));
assert!(!is_enabled_from_inputs(Some(""), missing_config));
assert!(!is_enabled_from_inputs(None, missing_config));
}
#[test]
fn is_enabled_reads_config_yaml_when_env_var_unset() {
let dir = tempfile::tempdir().expect("tempdir");
let cfg = dir.path().join("config.yaml");
assert!(!is_enabled_from_inputs(None, &cfg));
std::fs::write(&cfg, "inherited_context:\n enabled: true\n")
.expect("write enabled config");
assert!(is_enabled_from_inputs(None, &cfg));
std::fs::write(&cfg, "inherited_context:\n enabled: false\n")
.expect("write disabled config");
assert!(!is_enabled_from_inputs(None, &cfg));
std::fs::write(&cfg, "issue_prefix: bd\n").expect("write plain config");
assert!(!is_enabled_from_inputs(None, &cfg));
std::fs::write(&cfg, "this: is: not: yaml: [").expect("write malformed");
assert!(!is_enabled_from_inputs(None, &cfg));
}
}