Skip to main content

dial/db/
mod.rs

1pub mod schema;
2
3use crate::errors::{DialError, Result};
4use rusqlite::Connection;
5use std::env;
6use std::fs;
7use std::path::PathBuf;
8
9pub const DEFAULT_PHASE: &str = "default";
10
11pub fn get_dial_dir() -> PathBuf {
12    env::current_dir().unwrap_or_default().join(".dial")
13}
14
15pub fn get_db_path(phase: Option<&str>) -> PathBuf {
16    let dial_dir = get_dial_dir();
17    let phase = phase.unwrap_or_else(|| {
18        get_current_phase().unwrap_or_else(|_| DEFAULT_PHASE.to_string()).leak()
19    });
20    dial_dir.join(format!("{}.db", phase))
21}
22
23pub fn get_current_phase() -> Result<String> {
24    let phase_file = get_dial_dir().join("current_phase");
25    if phase_file.exists() {
26        Ok(fs::read_to_string(&phase_file)?.trim().to_string())
27    } else {
28        Ok(DEFAULT_PHASE.to_string())
29    }
30}
31
32pub fn set_current_phase(phase: &str) -> Result<()> {
33    let dial_dir = get_dial_dir();
34    let phase_file = dial_dir.join("current_phase");
35    fs::write(&phase_file, phase)?;
36    Ok(())
37}
38
39pub fn get_db(phase: Option<&str>) -> Result<Connection> {
40    let db_path = get_db_path(phase);
41    if !db_path.exists() {
42        return Err(DialError::NotInitialized);
43    }
44
45    let conn = Connection::open(&db_path)?;
46    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
47
48    // Run migrations
49    migrate_db(&conn)?;
50
51    Ok(conn)
52}
53
54pub fn init_db(phase: &str, import_solutions_from: Option<&str>, setup_agents: bool) -> Result<bool> {
55    let dial_dir = get_dial_dir();
56    fs::create_dir_all(&dial_dir)?;
57
58    let db_path = dial_dir.join(format!("{}.db", phase));
59
60    if db_path.exists() {
61        if !crate::output::prompt_yes_no(&format!(
62            "Warning: Database {} already exists. Overwrite?",
63            db_path.display()
64        )) {
65            println!("Aborted.");
66            return Ok(false);
67        }
68        fs::remove_file(&db_path)?;
69    }
70
71    let conn = Connection::open(&db_path)?;
72    conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
73    conn.execute_batch(schema::SCHEMA)?;
74
75    // Set default config
76    let now = chrono::Local::now().to_rfc3339();
77    let project_name = env::current_dir()
78        .ok()
79        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
80        .unwrap_or_else(|| "unknown".to_string());
81
82    let defaults = [
83        ("phase", phase),
84        ("project_name", &project_name),
85        ("build_cmd", ""),
86        ("test_cmd", ""),
87        ("build_timeout", "600"),
88        ("test_timeout", "600"),
89        ("created_at", &now),
90    ];
91
92    for (key, value) in defaults {
93        conn.execute(
94            "INSERT INTO config (key, value) VALUES (?1, ?2)",
95            [key, value],
96        )?;
97    }
98
99    // Import solutions from another phase if requested
100    if let Some(source_phase) = import_solutions_from {
101        import_trusted_solutions(&conn, &dial_dir, source_phase)?;
102    }
103
104    set_current_phase(phase)?;
105
106    crate::output::print_success(&format!("Initialized DIAL database: {}", db_path.display()));
107
108    if setup_agents {
109        setup_agents_md(true)?;
110    }
111
112    Ok(true)
113}
114
115fn import_trusted_solutions(conn: &Connection, dial_dir: &PathBuf, source_phase: &str) -> Result<()> {
116    let source_db_path = dial_dir.join(format!("{}.db", source_phase));
117    if !source_db_path.exists() {
118        return Err(DialError::PhaseNotFound(source_phase.to_string()));
119    }
120
121    let source_conn = Connection::open(&source_db_path)?;
122    source_conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
123
124    // Copy trusted solutions and their failure patterns
125    let mut stmt = source_conn.prepare(
126        "SELECT fp.* FROM failure_patterns fp
127         INNER JOIN solutions s ON s.pattern_id = fp.id
128         WHERE s.confidence >= ?1",
129    )?;
130
131    let patterns: Vec<_> = stmt
132        .query_map([crate::TRUST_THRESHOLD], |row| {
133            Ok((
134                row.get::<_, i64>(0)?,  // id
135                row.get::<_, String>(1)?, // pattern_key
136                row.get::<_, String>(2)?, // description
137                row.get::<_, Option<String>>(3)?, // category
138                row.get::<_, i64>(4)?, // occurrence_count
139                row.get::<_, String>(5)?, // first_seen_at
140                row.get::<_, Option<String>>(6)?, // last_seen_at
141            ))
142        })?
143        .collect::<std::result::Result<Vec<_>, _>>()?;
144
145    let mut pattern_id_map = std::collections::HashMap::new();
146
147    for (old_id, pattern_key, description, category, occurrence_count, first_seen_at, last_seen_at) in &patterns {
148        conn.execute(
149            "INSERT INTO failure_patterns (pattern_key, description, category, occurrence_count, first_seen_at, last_seen_at)
150             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
151            rusqlite::params![pattern_key, description, category, occurrence_count, first_seen_at, last_seen_at],
152        )?;
153        let new_id = conn.last_insert_rowid();
154        pattern_id_map.insert(*old_id, new_id);
155    }
156
157    // Copy solutions
158    let mut stmt = source_conn.prepare(
159        "SELECT * FROM solutions WHERE confidence >= ?1",
160    )?;
161
162    let solutions: Vec<_> = stmt
163        .query_map([crate::TRUST_THRESHOLD], |row| {
164            Ok((
165                row.get::<_, i64>(1)?,  // pattern_id
166                row.get::<_, String>(2)?, // description
167                row.get::<_, Option<String>>(3)?, // code_example
168                row.get::<_, f64>(4)?, // confidence
169                row.get::<_, i64>(5)?, // success_count
170                row.get::<_, i64>(6)?, // failure_count
171                row.get::<_, String>(7)?, // created_at
172                row.get::<_, Option<String>>(8)?, // last_used_at
173            ))
174        })?
175        .collect::<std::result::Result<Vec<_>, _>>()?;
176
177    let mut count = 0;
178    for (pattern_id, description, code_example, confidence, success_count, failure_count, created_at, last_used_at) in solutions {
179        if let Some(&new_pattern_id) = pattern_id_map.get(&pattern_id) {
180            conn.execute(
181                "INSERT INTO solutions (pattern_id, description, code_example, confidence, success_count, failure_count, created_at, last_used_at)
182                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
183                rusqlite::params![new_pattern_id, description, code_example, confidence, success_count, failure_count, created_at, last_used_at],
184            )?;
185            count += 1;
186        }
187    }
188
189    crate::output::print_success(&format!(
190        "Imported {} trusted solutions from '{}'.",
191        count, source_phase
192    ));
193
194    Ok(())
195}
196
197fn migrate_db(conn: &Connection) -> Result<()> {
198    // Check if learnings table exists
199    let has_learnings: bool = conn.query_row(
200        "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='learnings'",
201        [],
202        |row| row.get(0),
203    )?;
204
205    if !has_learnings {
206        conn.execute_batch(
207            r#"
208            CREATE TABLE IF NOT EXISTS learnings (
209                id INTEGER PRIMARY KEY,
210                category TEXT,
211                description TEXT NOT NULL,
212                discovered_at TEXT DEFAULT CURRENT_TIMESTAMP,
213                times_referenced INTEGER DEFAULT 0
214            );
215
216            CREATE VIRTUAL TABLE IF NOT EXISTS learnings_fts USING fts5(
217                category, description,
218                content='learnings', content_rowid='id',
219                tokenize='porter'
220            );
221
222            CREATE TRIGGER IF NOT EXISTS learnings_ai AFTER INSERT ON learnings BEGIN
223                INSERT INTO learnings_fts(rowid, category, description)
224                VALUES (NEW.id, COALESCE(NEW.category, ''), NEW.description);
225            END;
226
227            CREATE TRIGGER IF NOT EXISTS learnings_ad AFTER DELETE ON learnings BEGIN
228                INSERT INTO learnings_fts(learnings_fts, rowid, category, description)
229                VALUES('delete', OLD.id, COALESCE(OLD.category, ''), OLD.description);
230            END;
231
232            CREATE TRIGGER IF NOT EXISTS learnings_au AFTER UPDATE ON learnings BEGIN
233                INSERT INTO learnings_fts(learnings_fts, rowid, category, description)
234                VALUES('delete', OLD.id, COALESCE(OLD.category, ''), OLD.description);
235                INSERT INTO learnings_fts(rowid, category, description)
236                VALUES (NEW.id, COALESCE(NEW.category, ''), NEW.description);
237            END;
238            "#,
239        )?;
240    }
241
242    Ok(())
243}
244
245const DIAL_AGENTS_SECTION: &str = r#"
246---
247
248## DIAL — Autonomous Development Loop
249
250This project uses **DIAL** (Deterministic Iterative Agent Loop) for autonomous development.
251
252### Get Full Instructions
253
254```bash
255sqlite3 ~/projects/dial/dial_guide.db "SELECT content FROM sections WHERE section_id LIKE '2.%' ORDER BY sort_order;"
256```
257
258### Quick Reference
259
260```bash
261dial status           # Current state
262dial task list        # Show pending tasks
263dial task next        # Show next task
264dial iterate          # Start next task, get context
265dial validate         # Run tests, commit on success
266dial learn "text" -c category  # Record a learning
267dial stats            # Statistics dashboard
268```
269
270### The DIAL Loop
271
2721. `dial iterate` → Get task + context
2732. Implement (one task only, no placeholders, search before creating)
2743. `dial validate` → Test and commit
2754. On success: next task. On failure: retry (max 3).
276
277### Configuration
278
279```bash
280dial config set build_cmd "your build command"
281dial config set test_cmd "your test command"
282```
283"#;
284
285pub fn setup_agents_md(skip_if_exists: bool) -> Result<bool> {
286    let project_root = env::current_dir()?;
287    let agents_files = ["AGENTS.md", "CLAUDE.md", "GEMINI.md"];
288
289    // Find existing AGENTS.md
290    let mut agents_path: Option<PathBuf> = None;
291    for name in agents_files {
292        let path = project_root.join(name);
293        if path.exists() {
294            // Follow symlink if needed
295            let real_path = if path.is_symlink() {
296                fs::read_link(&path).ok().and_then(|p| {
297                    if p.is_absolute() {
298                        Some(p)
299                    } else {
300                        Some(project_root.join(p))
301                    }
302                })
303            } else {
304                Some(path.clone())
305            };
306
307            if let Some(rp) = real_path {
308                if rp.exists() && !rp.is_symlink() {
309                    agents_path = Some(rp);
310                    break;
311                }
312            }
313        }
314    }
315
316    let agents_path = match agents_path {
317        Some(p) => p,
318        None => {
319            // Create new AGENTS.md
320            let path = project_root.join("AGENTS.md");
321            let project_name = project_root
322                .file_name()
323                .map(|n| n.to_string_lossy().to_string())
324                .unwrap_or_else(|| "Project".to_string());
325
326            let content = format!(
327                "# Project: {}\n\n## On Entry (MANDATORY)\n\n```bash\nsession-context\n```\n{}",
328                project_name, DIAL_AGENTS_SECTION
329            );
330            fs::write(&path, content)?;
331            crate::output::print_success(&format!("Created {} with DIAL instructions.", path.display()));
332            return Ok(true);
333        }
334    };
335
336    // Check if DIAL section already exists
337    let existing_content = fs::read_to_string(&agents_path)?;
338    if existing_content.contains("## DIAL") || existing_content.contains("dial iterate") {
339        if skip_if_exists {
340            crate::output::print_info("DIAL section already exists in AGENTS.md.");
341            return Ok(true);
342        }
343        if !crate::output::prompt_yes_no("DIAL section already exists in AGENTS.md. Replace it?") {
344            println!("Skipped AGENTS.md update.");
345            return Ok(true);
346        }
347        // Remove existing DIAL section
348        let re = regex::Regex::new(r"\n---\n\n## DIAL.*?(?=\n---\n|\n## [^D]|\z)")
349            .unwrap();
350        let existing_content = re.replace(&existing_content, "").to_string();
351        let new_content = format!("{}{}", existing_content.trim_end(), DIAL_AGENTS_SECTION);
352        fs::write(&agents_path, new_content)?;
353    } else {
354        // Append DIAL section
355        let new_content = format!("{}\n{}", existing_content.trim_end(), DIAL_AGENTS_SECTION);
356        fs::write(&agents_path, new_content)?;
357    }
358
359    crate::output::print_success(&format!("Added DIAL instructions to {}", agents_path.display()));
360    Ok(true)
361}