use anyhow::Result;
use serde_json::{json, Value};
use super::super::{optional_u64_param, OutputForm, RecoverableError, Tool, ToolContext};
use crate::util::text::extract_lines;
pub struct ReadMarkdown;
async fn resolve_markdown_source(
path: &str,
ctx: &ToolContext,
) -> Result<(std::path::PathBuf, String)> {
if path.starts_with("@file_") {
let buf = ctx
.output_buffer
.get(path)
.ok_or_else(|| {
RecoverableError::with_hint(
format!("buffer reference not found: '{}'", path),
"Buffer refs expire when the session resets. Re-run read_markdown on the file to get a fresh ref.",
)
})?;
let resolved = buf
.source_path
.clone()
.unwrap_or_else(|| std::path::PathBuf::from(path));
Ok((resolved, buf.stdout.clone()))
} else {
if !path.ends_with(".md") && !path.ends_with(".markdown") {
return Err(RecoverableError::with_hint(
"read_markdown only supports .md files",
"Use read_file for non-markdown files.",
)
.into());
}
let project_root = ctx
.agent
.project_root_for(ctx.workspace_override.as_deref())
.await;
let security = ctx
.agent
.security_config_for(ctx.workspace_override.as_deref())
.await;
let resolved = crate::util::path_security::validate_read_path(
path,
project_root.as_deref(),
&security,
)?;
if resolved.is_dir() {
return Err(RecoverableError::with_hint(
format!("'{}' is a directory, not a file", path),
"Use tree to browse directory contents, or provide a specific file path",
)
.into());
}
let text = std::fs::read_to_string(&resolved).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => RecoverableError::with_hint(
format!("file not found: '{}'", path),
"Check the path with tree, or use tree with `glob` to locate the file",
)
.into(),
_ => anyhow::anyhow!("failed to read {}: {}", resolved.display(), e),
})?;
Ok((resolved, text))
}
}
fn read_markdown_multi_heading(
text: &str,
resolved: &std::path::PathBuf,
ctx: &ToolContext,
headings_arr: &[Value],
) -> Result<Value> {
let heading_queries: Vec<String> = headings_arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
let mut sections = Vec::new();
let mut seen_headings = Vec::new();
for query in &heading_queries {
let section = crate::tools::file_summary::extract_markdown_section(text, query)?;
seen_headings.push(
section
.breadcrumb
.last()
.cloned()
.unwrap_or_else(|| query.clone()),
);
sections.push(section.content);
}
let content = sections.join("\n\n");
if crate::tools::exceeds_inline_limit(&content) {
let file_id = ctx
.output_buffer
.store_file(resolved.to_string_lossy().to_string(), content.clone());
let lines = content.lines().count();
let hint = format!(
"use {:?} โ request one heading at a time, or slice with start_line/end_line",
file_id
);
let next_actions: Vec<String> = seen_headings
.iter()
.take(3)
.map(|h| format!("read_markdown({:?}, heading={})", file_id, h))
.collect();
let err = crate::tools::RecoverableError::with_hint(
format!(
"combined headings span {} lines โ exceeds inline threshold",
lines
),
hint,
)
.with_extra("file_id", serde_json::json!(file_id))
.with_extra("requested_headings", serde_json::json!(seen_headings))
.with_extra("next_actions", serde_json::json!(next_actions));
return Err(err.into());
}
if !seen_headings.is_empty() {
if let Ok(mut cov) = ctx.section_coverage.lock() {
cov.mark_seen(resolved, &seen_headings);
}
}
let mut result = json!({
"content": content,
});
let all_headings = crate::tools::file_summary::parse_all_headings(text);
if !all_headings.is_empty() {
let all_texts: Vec<String> = all_headings.iter().map(|h| h.text.clone()).collect();
if let Ok(mut cov) = ctx.section_coverage.lock() {
if let Some(status) = cov.status(resolved, &all_texts) {
if !status.unread.is_empty() {
result["coverage"] = json!({
"read": status.read_count,
"total": status.total_count,
"unread": status.unread,
});
}
}
}
}
Ok(result)
}
fn read_markdown_single_heading(
text: &str,
resolved: &std::path::PathBuf,
ctx: &ToolContext,
heading_query: &str,
) -> Result<Value> {
let section_result =
match crate::tools::file_summary::extract_markdown_section(text, heading_query) {
Ok(s) => s,
Err(e) => {
let msg = e.message.clone();
if msg.contains("not found") {
let headings_json: Vec<serde_json::Value> =
crate::tools::file_summary::parse_all_headings(text)
.iter()
.map(|h| serde_json::json!({"h": h.text, "l": h.line}))
.collect();
return Ok(json!({
"ok": false,
"error": format!("heading {:?} not found", heading_query),
"headings": headings_json,
"hint": "pick a heading from the list above, or use start_line/end_line",
}));
}
return Err(e.into());
}
};
let cov = crate::tools::read_file::markdown_coverage(
text,
resolved,
ctx,
Some(heading_query),
None,
None,
);
if crate::tools::exceeds_inline_limit(§ion_result.content) {
let file_id = ctx.output_buffer.store_file(
resolved.to_string_lossy().to_string(),
section_result.content.clone(),
);
let section_lines = section_result.content.lines().count();
let (start_ln, end_ln) = section_result.line_range;
let all_headings = crate::tools::file_summary::parse_all_headings(text);
let nested: Vec<serde_json::Value> = all_headings
.iter()
.filter(|h| h.line > start_ln && h.line <= end_ln)
.map(|h| json!({"h": h.text, "l": h.line}))
.collect();
let heading_label = section_result
.breadcrumb
.last()
.cloned()
.unwrap_or_else(|| heading_query.to_string());
let hint = format!(
"use {:?} โ pick a sub-heading from `section_map` or start_line/end_line",
file_id
);
let next_actions: Vec<String> = {
let mut actions = Vec::new();
if let Some(first) = nested.first() {
if let Some(h) = first.get("h").and_then(|v| v.as_str()) {
actions.push(format!("read_markdown({:?}, heading={})", file_id, h));
}
}
actions.push(format!(
"read_markdown({:?}, start_line={}, end_line={})",
file_id,
start_ln,
start_ln + 100.min(section_lines)
));
actions
};
let err = crate::tools::RecoverableError::with_hint(
format!(
"section {:?} spans {} lines โ exceeds inline threshold",
heading_label, section_lines
),
hint,
)
.with_extra("file_id", serde_json::json!(file_id))
.with_extra("section_map", serde_json::json!(nested))
.with_extra("next_actions", serde_json::json!(next_actions))
.with_extra("breadcrumb", serde_json::json!(section_result.breadcrumb))
.with_extra("line_range", serde_json::json!([start_ln, end_ln]));
return Err(err.into());
}
let mut val = json!({
"content": section_result.content,
"lines": section_result.content.lines().count(),
"line_range": [section_result.line_range.0, section_result.line_range.1],
"breadcrumb": section_result.breadcrumb,
"siblings": section_result.siblings,
});
if let Some(c) = cov {
val["coverage"] = c;
}
Ok(val)
}
fn read_markdown_line_range(
text: &str,
resolved: &std::path::PathBuf,
ctx: &ToolContext,
start: u64,
end: u64,
) -> Result<Value> {
if start == 0 || end < start {
return Err(RecoverableError::with_hint(
format!(
"invalid line range: start_line={} end_line={} \
(start_line must be >= 1 and end_line >= start_line)",
start, end
),
"Lines are 1-indexed. Example: start_line=1, end_line=50",
)
.into());
}
let content_total = text.lines().count();
if (start as usize) > content_total {
return Err(RecoverableError::with_hint(
format!(
"start_line {} exceeds file length {}",
start, content_total
),
format!(
"valid range is 1..={}; use read_markdown(path, start_line=N, end_line=M) within bounds",
content_total
),
)
.with_extra("lines", serde_json::json!(content_total))
.into());
}
let content = extract_lines(text, start as usize, end as usize);
let md_cov = crate::tools::read_file::markdown_coverage(
text,
resolved,
ctx,
None,
Some(start),
Some(end),
);
if crate::tools::exceeds_inline_limit(&content) {
let content_total = content.lines().count();
let file_id = ctx
.output_buffer
.store_file(resolved.to_string_lossy().to_string(), content.clone());
let (chunk, lines_shown, complete) = crate::util::text::extract_lines_to_budget(
&content,
1,
usize::MAX,
crate::tools::INLINE_BYTE_BUDGET,
);
let orig_start = start as usize;
let orig_end = orig_start + lines_shown.saturating_sub(1);
let mut result = json!({
"content": chunk,
"file_id": file_id,
"total_lines": content_total,
"shown_lines": [orig_start, orig_end],
"complete": complete,
});
if !complete {
let buf_next_start = lines_shown + 1;
let buf_next_end = (buf_next_start + lines_shown - 1).min(content_total);
result["next"] = json!(format!(
"read_markdown(\"{file_id}\", start_line={buf_next_start}, \
end_line={buf_next_end})"
));
}
if let Some(c) = md_cov {
result["coverage"] = c;
}
return Ok(result);
}
let mut result = json!({ "content": content });
if let Some(c) = md_cov {
result["coverage"] = c;
}
Ok(result)
}
fn read_markdown_default_tiers(
text: &str,
resolved: &std::path::PathBuf,
ctx: &ToolContext,
) -> Result<Value> {
let total_lines = text.lines().count();
let oversized = crate::tools::exceeds_inline_limit(text);
let all_headings = crate::tools::file_summary::parse_all_headings(text);
let oversized_by_headings = all_headings.len() > crate::tools::HEADINGS_HARD_CAP;
let md_cov = crate::tools::read_file::markdown_coverage(text, resolved, ctx, None, None, None);
if oversized || oversized_by_headings {
let headings_json: Vec<Value> = all_headings
.iter()
.map(|h| json!({"h": h.text, "l": h.line}))
.collect();
let file_id = ctx
.output_buffer
.store_file(resolved.to_string_lossy().to_string(), text.to_string());
let hint = if all_headings.is_empty() {
format!("use {:?} โ start_line/end_line", file_id)
} else {
format!(
"use {:?} โ heading=\"## Section\" or start_line/end_line",
file_id
)
};
let mut result = json!({
"lines": total_lines,
"headings": headings_json,
"file_id": file_id,
"hint": hint,
});
if let Some(c) = md_cov {
result["coverage"] = c;
}
return Ok(result);
}
if total_lines > crate::tools::LINE_SOFT_CAP {
let heading_count = all_headings.len();
let hint = if heading_count == 0 {
format!(
"{} lines, no headings โ read_markdown(path, start_line=N, end_line=M) to focus",
total_lines
)
} else {
format!(
"{} lines, {} sections โ read_markdown(path, heading=\"## Section\") to focus",
total_lines, heading_count
)
};
let mut result = json!({
"content": text,
"lines": total_lines,
"hint": hint,
});
if let Some(c) = md_cov {
result["coverage"] = c;
}
return Ok(result);
}
let mut result = json!({
"content": text,
"lines": total_lines,
});
if let Some(c) = md_cov {
result["coverage"] = c;
}
let heading_count = all_headings.len();
if heading_count >= 2 {
result["hint"] = serde_json::json!(format!(
"{} lines, {} sections โ read_markdown(path, heading=\"## Section\") to focus",
total_lines, heading_count
));
}
Ok(result)
}
#[async_trait::async_trait]
impl Tool for ReadMarkdown {
fn name(&self) -> &str {
"read_markdown"
}
fn description(&self) -> &str {
"Read a Markdown file with heading-based navigation. Returns heading map by default, \
or targeted sections via heading/headings params."
}
fn relevant_guide_topic(&self) -> Option<&str> {
Some("progressive-disclosure")
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"required": ["path"],
"properties": {
"path": { "type": "string", "description": "Markdown file path relative to project root" },
"heading": { "type": "string", "description": "Markdown section by heading (e.g. \"## Auth\")." },
"headings": {
"type": "array",
"items": { "type": "string" },
"description": "List of headings to read (returns multiple sections). Mutually exclusive with heading."
},
"start_line": { "type": "integer", "description": "First line (1-indexed). Pair with end_line." },
"end_line": { "type": "integer", "description": "Last line (1-indexed, inclusive). Pair with start_line." }
}
})
}
async fn call(&self, input: Value, ctx: &ToolContext) -> Result<Value> {
let path = crate::tools::require_str_param(&input, "path")?;
let (resolved, text) = resolve_markdown_source(path, ctx).await?;
crate::util::librarian_guard::guard_not_librarian_managed(path, &text)?;
let heading = input["heading"].as_str();
let headings_param = crate::tools::optional_array_param(&input, "headings");
let start_line = optional_u64_param(&input, "start_line");
let end_line = optional_u64_param(&input, "end_line");
if heading.is_some() && headings_param.is_some() {
return Err(RecoverableError::with_hint(
"heading and headings are mutually exclusive",
"Use heading for a single section, or headings for multiple sections.",
)
.into());
}
let has_nav = heading.is_some() || headings_param.is_some();
let has_range = start_line.is_some() || end_line.is_some();
if has_nav && has_range {
return Err(RecoverableError::with_hint(
"navigation parameters are mutually exclusive with start_line/end_line",
"Use heading/headings OR start_line+end_line, not both",
)
.into());
}
if start_line.is_some() != end_line.is_some() {
return Err(RecoverableError::with_hint(
"both start_line and end_line are required",
"Provide both start_line and end_line for a line range, e.g. start_line=1, end_line=50",
)
.into());
}
if let Some(headings_arr) = headings_param {
return read_markdown_multi_heading(&text, &resolved, ctx, &headings_arr);
}
if let Some(heading_query) = heading {
return read_markdown_single_heading(&text, &resolved, ctx, heading_query);
}
if let (Some(start), Some(end)) = (start_line, end_line) {
return read_markdown_line_range(&text, &resolved, ctx, start, end);
}
read_markdown_default_tiers(&text, &resolved, ctx)
}
fn output_form(&self) -> OutputForm {
OutputForm::Text
}
fn format_compact(&self, result: &Value) -> Option<String> {
let is_error = result
.get("ok")
.and_then(|v| v.as_bool())
.map(|ok| !ok)
.unwrap_or(false);
if is_error {
let mut out = String::from("error: ");
if let Some(msg) = result.get("error").and_then(|v| v.as_str()) {
out.push_str(msg);
}
out.push_str("\n\n");
if let Some(headings) = result.get("headings").and_then(|v| v.as_array()) {
out.push_str("available headings:\n");
for entry in headings {
let h = entry.get("h").and_then(|v| v.as_str()).unwrap_or("");
let l = entry.get("l").and_then(|v| v.as_u64()).unwrap_or(0);
let level = h.chars().take_while(|c| *c == '#').count().max(1);
let indent = " ".repeat((level - 1) * 2);
out.push_str(&format!("{indent}{h} L{l}\n"));
}
}
if let Some(hint) = result.get("hint").and_then(|v| v.as_str()) {
out.push('\n');
out.push_str("next: ");
out.push_str(hint);
}
return Some(out);
}
if let Some(content) = result.get("content").and_then(|v| v.as_str()) {
let mut out = String::new();
if let (Some(breadcrumb), Some(line_range)) = (
result.get("breadcrumb").and_then(|v| v.as_array()),
result.get("line_range").and_then(|v| v.as_array()),
) {
if let (Some(last), Some(start), Some(end)) = (
breadcrumb.last().and_then(|v| v.as_str()),
line_range.first().and_then(|v| v.as_u64()),
line_range.get(1).and_then(|v| v.as_u64()),
) {
out.push_str(&format!("ยง {last} L{start}-L{end}\n\n"));
}
}
out.push_str(content);
if let Some(hint) = result.get("hint").and_then(|v| v.as_str()) {
if !out.ends_with('\n') {
out.push('\n');
}
out.push('\n');
out.push_str(hint);
}
return Some(out);
}
let headings = result
.get("headings")
.or_else(|| result.get("section_map"))
.and_then(|v| v.as_array());
if let Some(headings) = headings {
let lines = result.get("lines").and_then(|v| v.as_u64()).unwrap_or(0);
let file_id = result.get("file_id").and_then(|v| v.as_str()).unwrap_or("");
let mut out = format!("{} lines {}\n\n", lines, file_id);
for entry in headings {
let h = entry.get("h").and_then(|v| v.as_str()).unwrap_or("");
let l = entry.get("l").and_then(|v| v.as_u64()).unwrap_or(0);
let level = h.chars().take_while(|c| *c == '#').count().max(1);
let indent = " ".repeat((level - 1) * 2);
out.push_str(&format!("{indent}{h} L{l}\n"));
}
if let Some(hint) = result.get("hint").and_then(|v| v.as_str()) {
out.push('\n');
out.push_str("next: ");
out.push_str(hint);
}
return Some(out);
}
Some(result.to_string())
}
}