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 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 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 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 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)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?, row.get::<_, i64>(4)?, row.get::<_, String>(5)?, row.get::<_, Option<String>>(6)?, ))
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 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)?, row.get::<_, String>(2)?, row.get::<_, Option<String>>(3)?, row.get::<_, f64>(4)?, row.get::<_, i64>(5)?, row.get::<_, i64>(6)?, row.get::<_, String>(7)?, row.get::<_, Option<String>>(8)?, ))
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 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 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 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 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 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 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 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}