use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use cqs::audit::load_audit_state;
use cqs::note::{parse_notes, path_matches_mention, Note};
use cqs::parser::ChunkType;
use cqs::store::Store;
use cqs::{compute_hints, FunctionHints, COMMON_TYPES};
pub(crate) fn validate_and_read_file(root: &Path, path: &str) -> Result<(PathBuf, String)> {
let file_path = root.join(path);
if !file_path.exists() {
bail!("File not found: {}", path);
}
let canonical = dunce::canonicalize(&file_path)
.with_context(|| format!("Failed to canonicalize path: {}", path))?;
let project_canonical =
dunce::canonicalize(root).context("Failed to canonicalize project root")?;
if !canonical.starts_with(&project_canonical) {
bail!("Path traversal not allowed: {}", path);
}
const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
let metadata = std::fs::metadata(&file_path).context("Failed to read file metadata")?;
if metadata.len() > MAX_FILE_SIZE {
bail!(
"File too large: {} bytes (max {} bytes)",
metadata.len(),
MAX_FILE_SIZE
);
}
let content = std::fs::read_to_string(&canonical).context("Failed to read file")?;
Ok((file_path, content))
}
pub(crate) fn build_file_note_header(
path: &str,
file_path: &Path,
audit_state: &cqs::audit::AuditMode,
notes: &[Note],
) -> (String, bool) {
let mut header = String::new();
let mut notes_injected = false;
if let Some(status) = audit_state.status_line() {
header.push_str(&format!("// {}\n//\n", status));
}
if !audit_state.is_active() {
let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let relevant: Vec<_> = notes
.iter()
.filter(|n| {
n.mentions
.iter()
.any(|m| m == file_name || m == path || path_matches_mention(path, m))
})
.collect();
if !relevant.is_empty() {
notes_injected = true;
header.push_str("// ┌─────────────────────────────────────────────────────────────┐\n");
header.push_str("// │ [cqs] Context from notes.toml │\n");
header.push_str("// └─────────────────────────────────────────────────────────────┘\n");
for n in relevant {
if let Some(first_line) = n.text.lines().next() {
header.push_str(&format!(
"// [{}] {}\n",
n.sentiment_label(),
first_line.trim()
));
}
}
header.push_str("//\n");
}
}
(header, notes_injected)
}
pub(crate) struct FocusedReadResult {
pub output: String,
pub hints: Option<FunctionHints>,
}
pub(crate) fn build_focused_output(
store: &Store,
focus: &str,
root: &Path,
audit_state: &cqs::audit::AuditMode,
notes: &[Note],
) -> Result<FocusedReadResult> {
let resolved = cqs::resolve_target(store, focus)?;
let chunk = &resolved.chunk;
let rel_file = cqs::rel_display(&chunk.file, root);
let mut output = String::new();
output.push_str(&format!(
"// [cqs] Focused read: {} ({}:{}-{})\n",
chunk.name, rel_file, chunk.line_start, chunk.line_end
));
let hints = if chunk.chunk_type.is_callable() {
match compute_hints(store, &chunk.name, None) {
Ok(h) => Some(h),
Err(e) => {
tracing::warn!(function = %chunk.name, error = %e, "Failed to compute hints");
None
}
}
} else {
None
};
if let Some(ref h) = hints {
let caller_label = if h.caller_count == 0 {
"! 0 callers".to_string()
} else {
format!("{} callers", h.caller_count)
};
let test_label = if h.test_count == 0 {
"! 0 tests".to_string()
} else {
format!("{} tests", h.test_count)
};
output.push_str(&format!("// [cqs] {} | {}\n", caller_label, test_label));
}
if let Some(status) = audit_state.status_line() {
output.push_str(&format!("// {}\n", status));
}
if !audit_state.is_active() {
let relevant: Vec<_> = notes
.iter()
.filter(|n| {
n.mentions
.iter()
.any(|m| m == &chunk.name || m == &rel_file)
})
.collect();
for n in &relevant {
if let Some(first_line) = n.text.lines().next() {
output.push_str(&format!(
"// [{}] {}\n",
n.sentiment_label(),
first_line.trim()
));
}
}
if !relevant.is_empty() {
output.push_str("//\n");
}
}
output.push_str("\n// --- Target ---\n");
if let Some(ref doc) = chunk.doc {
output.push_str(doc);
output.push('\n');
}
output.push_str(&chunk.content);
output.push('\n');
let type_deps = match store.get_types_used_by(&chunk.name) {
Ok(pairs) => pairs,
Err(e) => {
tracing::warn!(function = %chunk.name, error = %e, "Failed to query type deps");
Vec::new()
}
};
let mut seen_types = std::collections::HashSet::new();
let filtered_types: Vec<cqs::store::TypeUsage> = type_deps
.into_iter()
.filter(|t| !COMMON_TYPES.contains(t.type_name.as_str()))
.filter(|t| seen_types.insert(t.type_name.clone()))
.collect();
tracing::debug!(
type_count = filtered_types.len(),
"Type deps for focused read"
);
let type_names: Vec<&str> = filtered_types
.iter()
.map(|t| t.type_name.as_str())
.collect();
let batch_results = store
.search_by_names_batch(&type_names, 5)
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "Failed to batch-lookup type definitions for focused read");
std::collections::HashMap::new()
});
for t in &filtered_types {
let type_name = &t.type_name;
let edge_kind = &t.edge_kind;
if let Some(results) = batch_results.get(type_name.as_str()) {
let type_def = results.iter().find(|r| {
r.chunk.name == *type_name
&& matches!(
r.chunk.chunk_type,
ChunkType::Struct
| ChunkType::Enum
| ChunkType::Trait
| ChunkType::Interface
| ChunkType::Class
)
});
if let Some(r) = type_def {
let dep_rel = cqs::rel_display(&r.chunk.file, root);
let kind_label = if edge_kind.is_empty() {
String::new()
} else {
format!(" [{}]", edge_kind)
};
output.push_str(&format!(
"\n// --- Type: {}{} ({}:{}-{}) ---\n",
r.chunk.name, kind_label, dep_rel, r.chunk.line_start, r.chunk.line_end
));
output.push_str(&r.chunk.content);
output.push('\n');
}
}
}
Ok(FocusedReadResult { output, hints })
}
#[derive(Debug, serde::Serialize)]
struct ReadOutput {
path: String,
content: String,
}
#[derive(Debug, serde::Serialize)]
struct ReadHints {
caller_count: usize,
test_count: usize,
no_callers: bool,
no_tests: bool,
}
#[derive(Debug, serde::Serialize)]
struct FocusedReadJsonOutput {
focus: String,
content: String,
#[serde(skip_serializing_if = "Option::is_none")]
hints: Option<ReadHints>,
}
pub(crate) fn cmd_read(
ctx: &crate::cli::CommandContext,
path: &str,
focus: Option<&str>,
json: bool,
) -> Result<()> {
let _span = tracing::info_span!("cmd_read", path).entered();
if let Some(focus) = focus {
return cmd_read_focused(ctx, focus, json);
}
let root = &ctx.root;
let (file_path, content) = validate_and_read_file(root, path)?;
let cqs_dir = &ctx.cqs_dir;
let audit_mode = load_audit_state(cqs_dir);
let notes_path = root.join("docs/notes.toml");
let notes = if notes_path.exists() {
parse_notes(¬es_path).unwrap_or_else(|e| {
tracing::warn!(path = %notes_path.display(), error = %e, "Failed to parse notes.toml");
vec![]
})
} else {
vec![]
};
let (header, _notes_injected) = build_file_note_header(path, &file_path, &audit_mode, ¬es);
let enriched = if header.is_empty() {
content
} else {
format!("{}{}", header, content)
};
if json {
let result = ReadOutput {
path: path.to_string(),
content: enriched,
};
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
print!("{}", enriched);
}
Ok(())
}
fn cmd_read_focused(ctx: &crate::cli::CommandContext, focus: &str, json: bool) -> Result<()> {
let _span = tracing::info_span!("cmd_read_focused", %focus).entered();
let store = &ctx.store;
let root = &ctx.root;
let cqs_dir = &ctx.cqs_dir;
let audit_mode = load_audit_state(cqs_dir);
let notes_path = root.join("docs/notes.toml");
let notes = if notes_path.exists() {
parse_notes(¬es_path).unwrap_or_else(|e| {
tracing::warn!(path = %notes_path.display(), error = %e, "Failed to parse notes.toml in focused read");
vec![]
})
} else {
vec![]
};
let result = build_focused_output(store, focus, root, &audit_mode, ¬es)?;
if json {
let hints = result.hints.as_ref().map(|h| ReadHints {
caller_count: h.caller_count,
test_count: h.test_count,
no_callers: h.caller_count == 0,
no_tests: h.test_count == 0,
});
let output = FocusedReadJsonOutput {
focus: focus.to_string(),
content: result.output,
hints,
};
println!("{}", serde_json::to_string_pretty(&output)?);
} else {
print!("{}", result.output);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_output_serialization() {
let output = ReadOutput {
path: "src/lib.rs".into(),
content: "fn main() {}".into(),
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["path"], "src/lib.rs");
assert_eq!(json["content"], "fn main() {}");
}
#[test]
fn focused_read_output_with_hints() {
let output = FocusedReadJsonOutput {
focus: "search".into(),
content: "fn search() { ... }".into(),
hints: Some(ReadHints {
caller_count: 3,
test_count: 2,
no_callers: false,
no_tests: false,
}),
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["focus"], "search");
assert_eq!(json["hints"]["caller_count"], 3);
assert_eq!(json["hints"]["test_count"], 2);
assert_eq!(json["hints"]["no_callers"], false);
assert_eq!(json["hints"]["no_tests"], false);
}
#[test]
fn focused_read_output_no_hints() {
let output = FocusedReadJsonOutput {
focus: "MyStruct".into(),
content: "struct MyStruct {}".into(),
hints: None,
};
let json = serde_json::to_value(&output).unwrap();
assert_eq!(json["focus"], "MyStruct");
assert!(json.get("hints").is_none());
}
}