Skip to main content

dial/spec/
mod.rs

1pub mod parser;
2
3use crate::db::get_db;
4use crate::errors::{DialError, Result};
5use crate::output::{blue, bold, dim, print_success, yellow};
6use std::env;
7use std::path::Path;
8use walkdir::WalkDir;
9
10pub fn index_specs(specs_dir: &str) -> Result<bool> {
11    let specs_path = Path::new(specs_dir);
12
13    if !specs_path.exists() {
14        return Err(DialError::SpecsDirNotFound(specs_dir.to_string()));
15    }
16
17    let conn = get_db(None)?;
18
19    // Clear existing spec sections
20    conn.execute("DELETE FROM spec_sections", [])?;
21
22    // Find all markdown files
23    let md_files: Vec<_> = WalkDir::new(specs_path)
24        .into_iter()
25        .filter_map(|e| e.ok())
26        .filter(|e| {
27            e.path()
28                .extension()
29                .map(|ext| ext == "md")
30                .unwrap_or(false)
31        })
32        .collect();
33
34    if md_files.is_empty() {
35        println!("{}", yellow(&format!("No markdown files found in '{}'.", specs_dir)));
36        return Ok(true);
37    }
38
39    let cwd = env::current_dir()?;
40    let mut total_sections = 0;
41
42    for entry in &md_files {
43        let md_path = entry.path();
44        let relative_path = md_path
45            .strip_prefix(&cwd)
46            .unwrap_or(md_path)
47            .to_string_lossy()
48            .to_string();
49
50        let sections = parser::parse_markdown_sections(md_path)?;
51
52        for section in sections {
53            if !section.content.is_empty() {
54                conn.execute(
55                    "INSERT INTO spec_sections (file_path, heading_path, level, content)
56                     VALUES (?1, ?2, ?3, ?4)",
57                    rusqlite::params![relative_path, section.heading_path, section.level, section.content],
58                )?;
59                total_sections += 1;
60            }
61        }
62    }
63
64    print_success(&format!(
65        "Indexed {} sections from {} files.",
66        total_sections,
67        md_files.len()
68    ));
69
70    Ok(true)
71}
72
73pub fn spec_search(query: &str) -> Result<Vec<SpecSearchResult>> {
74    let conn = get_db(None)?;
75
76    let mut stmt = conn.prepare(
77        "SELECT s.id, s.file_path, s.heading_path, s.content
78         FROM spec_sections s
79         INNER JOIN spec_sections_fts fts ON s.id = fts.rowid
80         WHERE spec_sections_fts MATCH ?1
81         ORDER BY rank
82         LIMIT 10",
83    )?;
84
85    let rows: Vec<SpecSearchResult> = stmt
86        .query_map([query], |row| {
87            Ok(SpecSearchResult {
88                id: row.get(0)?,
89                file_path: row.get(1)?,
90                heading_path: row.get(2)?,
91                content: row.get(3)?,
92            })
93        })?
94        .collect::<std::result::Result<Vec<_>, _>>()?;
95
96    if rows.is_empty() {
97        println!("{}", dim(&format!("No spec sections matching '{}'.", query)));
98        return Ok(rows);
99    }
100
101    println!("{}", bold(&format!("Spec sections matching '{}':", query)));
102    println!("{}", "=".repeat(60));
103
104    for row in &rows {
105        let id_str = format!("[{}]", row.id);
106        println!("\n{} {}", blue(&id_str), bold(&row.heading_path));
107        println!("{}", dim(&format!("    File: {}", row.file_path)));
108        // Show first 200 chars of content
109        let preview = if row.content.len() > 200 {
110            format!("{}...", &row.content[..200])
111        } else {
112            row.content.clone()
113        };
114        println!("    {}", preview);
115    }
116
117    Ok(rows)
118}
119
120#[derive(Debug, Clone)]
121pub struct SpecSearchResult {
122    pub id: i64,
123    pub file_path: String,
124    pub heading_path: String,
125    pub content: String,
126}
127
128pub fn spec_show(section_id: i64) -> Result<Option<SpecSearchResult>> {
129    let conn = get_db(None)?;
130
131    let mut stmt = conn.prepare(
132        "SELECT id, file_path, heading_path, content FROM spec_sections WHERE id = ?1",
133    )?;
134
135    let result = stmt
136        .query_row([section_id], |row| {
137            Ok(SpecSearchResult {
138                id: row.get(0)?,
139                file_path: row.get(1)?,
140                heading_path: row.get(2)?,
141                content: row.get(3)?,
142            })
143        })
144        .ok();
145
146    match &result {
147        Some(spec) => {
148            println!("{}", bold(&spec.heading_path));
149            println!("{}", dim(&format!("File: {}", spec.file_path)));
150            println!("{}", "=".repeat(60));
151            println!("{}", spec.content);
152        }
153        None => {
154            return Err(DialError::SpecSectionNotFound(section_id));
155        }
156    }
157
158    Ok(result)
159}
160
161pub fn spec_list() -> Result<()> {
162    let conn = get_db(None)?;
163
164    let mut stmt = conn.prepare(
165        "SELECT id, file_path, heading_path, level
166         FROM spec_sections ORDER BY file_path, id",
167    )?;
168
169    let rows: Vec<(i64, String, String, i32)> = stmt
170        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?
171        .collect::<std::result::Result<Vec<_>, _>>()?;
172
173    if rows.is_empty() {
174        println!("{}", dim("No spec sections indexed. Run 'dial index' first."));
175        return Ok(());
176    }
177
178    println!("{}", bold("Indexed Spec Sections"));
179    println!("{}", "=".repeat(60));
180
181    let mut current_file = String::new();
182    for (id, file_path, heading_path, level) in rows {
183        if file_path != current_file {
184            current_file = file_path.clone();
185            println!("\n{}", blue(&current_file));
186        }
187        let indent = "  ".repeat(level as usize);
188        println!("  {}[{}] {}", indent, id, heading_path);
189    }
190
191    Ok(())
192}