use crate::config::get_cwd;
use crate::error::{Result, SkillcError};
use crate::index;
use crate::logging::{LogEntry, get_run_id, init_log_db, log_access_with_fallback};
use crate::resolver::{ResolvedSkill, resolve_skill};
use crate::{OutputFormat, verbose};
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use super::extract_headings;
pub fn show(
skill: &str,
section: &str,
file: Option<&str>,
max_lines: Option<usize>,
format: OutputFormat,
) -> Result<String> {
let start = Instant::now();
let resolved = resolve_skill(skill)?;
let run_id = get_run_id();
verbose!(
"show: section=\"{}\" file={:?} max_lines={:?}",
section,
file,
max_lines
);
verbose!("show: source_dir={}", resolved.source_dir.display());
let log_conn = init_log_db(&resolved.runtime_dir);
let result = do_show(&resolved, section, file, max_lines, &format);
verbose!("show: completed in {:?}", start.elapsed());
let args = match &result {
Ok((_, matched_file)) => serde_json::json!({
"section": section,
"file": matched_file.to_string_lossy(),
"max_lines": max_lines,
}),
Err(_) => serde_json::json!({
"section": section,
"file": file,
"max_lines": max_lines,
}),
};
log_access_with_fallback(
log_conn.as_ref(),
&LogEntry {
run_id,
command: "show".to_string(),
skill: resolved.name.clone(),
skill_path: resolved.source_dir.to_string_lossy().to_string(),
cwd: get_cwd(),
args: args.to_string(),
error: result.as_ref().err().map(|e| e.to_string()),
},
);
result.map(|(content, _)| content)
}
fn normalize_query(query: &str) -> String {
let trimmed = query.trim();
if let Some(idx) = trimmed.find(" — ") {
trimmed[..idx].trim().to_string()
} else {
trimmed.to_string()
}
}
fn format_suggestions(suggestions: &[index::HeadingEntry]) -> String {
if suggestions.is_empty() {
return String::new();
}
let mut result = String::from("\n\nDid you mean one of these?");
for entry in suggestions.iter().take(5) {
result.push_str(&format!("\n - {} ({})", entry.text, entry.file));
}
result
}
fn do_show(
resolved: &ResolvedSkill,
section: &str,
file: Option<&str>,
max_lines: Option<usize>,
_format: &OutputFormat,
) -> Result<(String, PathBuf)> {
let query = normalize_query(section);
verbose!("show: normalized query=\"{}\"", query);
match index::open_index(&resolved.runtime_dir, &resolved.source_dir, &resolved.name) {
Ok(conn) => do_show_with_index(&conn, resolved, &query, section, file, max_lines),
Err(_) => {
verbose!("show: index unavailable, falling back to runtime parsing");
do_show_fallback(resolved, &query, file, max_lines)
}
}
}
fn do_show_with_index(
conn: &Connection,
resolved: &ResolvedSkill,
query: &str,
original_section: &str,
file: Option<&str>,
max_lines: Option<usize>,
) -> Result<(String, PathBuf)> {
let matches = index::query_headings(conn, query, file)?;
if matches.is_empty() {
let suggestions = index::get_suggestions(conn, query, 5)?;
let suggestion_text = format_suggestions(&suggestions);
return Err(SkillcError::SectionNotFoundWithSuggestions(
original_section.to_string(),
suggestion_text,
));
}
if matches.len() > 1 {
crate::error::SkillcWarning::MultipleMatches(original_section.to_string()).emit();
}
let matched = &matches[0];
let file_path = resolved.source_dir.join(&matched.file);
let content = fs::read_to_string(&file_path)?;
let lines: Vec<&str> = content.lines().collect();
let start_idx = matched.start_line.saturating_sub(1);
let end_idx = (matched.end_line.saturating_sub(1)).min(lines.len());
let content_lines: Vec<&str> = lines[start_idx..end_idx].to_vec();
extract_output(content_lines, max_lines, PathBuf::from(&matched.file))
}
fn do_show_fallback(
resolved: &ResolvedSkill,
query: &str,
file: Option<&str>,
max_lines: Option<usize>,
) -> Result<(String, PathBuf)> {
use lazy_regex::{Lazy, Regex, lazy_regex};
static HEADING_LEVEL_RE: Lazy<Regex> = lazy_regex!(r"^(#{1,6})\s+");
let query_lower = query.to_lowercase();
let headings = extract_headings(&resolved.source_dir)?;
let filtered: Vec<_> = if let Some(file_path) = file {
let target = PathBuf::from(file_path);
headings.into_iter().filter(|h| h.file == target).collect()
} else {
headings
};
let matches: Vec<_> = filtered
.iter()
.filter(|h| h.text.trim().to_lowercase() == query_lower)
.collect();
if matches.is_empty() {
return Err(SkillcError::SectionNotFound(query.to_string()));
}
if matches.len() > 1 {
crate::error::SkillcWarning::MultipleMatches(query.to_string()).emit();
}
let matched = matches[0];
let file_path = resolved.source_dir.join(&matched.file);
let content = fs::read_to_string(&file_path)?;
let lines: Vec<&str> = content.lines().collect();
let start_line = matched.line_number;
let mut end_line = lines.len();
for (i, line) in lines.iter().enumerate().skip(start_line) {
if let Some(caps) = HEADING_LEVEL_RE.captures(line) {
let level = caps
.get(1)
.ok_or_else(|| SkillcError::Internal("regex group 1 missing".into()))?
.as_str()
.len();
if level <= matched.level {
end_line = i;
break;
}
}
}
let content_lines: Vec<&str> = lines[start_line - 1..end_line].to_vec();
extract_output(content_lines, max_lines, matched.file.clone())
}
fn extract_output(
content_lines: Vec<&str>,
max_lines: Option<usize>,
matched_file: PathBuf,
) -> Result<(String, PathBuf)> {
let output = if let Some(limit) = max_lines {
if content_lines.len() > limit {
let truncated: Vec<&str> = content_lines[..limit].to_vec();
let remaining = content_lines.len() - limit;
format!("{}\n... ({} more lines)", truncated.join("\n"), remaining)
} else {
content_lines.join("\n")
}
} else {
content_lines.join("\n")
};
Ok((output, matched_file))
}