Skip to main content

skillc/
sync.rs

1//! Log synchronization per [[RFC-0007:C-SYNC]]
2
3use crate::config::global_runtime_store;
4use crate::error::{Result, SkillcError};
5use crate::verbose;
6use rusqlite::Connection;
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11/// Options for sync command
12pub struct SyncOptions {
13    /// Specific skill to sync (None = all)
14    pub skill: Option<String>,
15    /// Project directory to sync from (None = CWD)
16    pub project: Option<PathBuf>,
17    /// Dry run mode
18    pub dry_run: bool,
19}
20
21/// Sync result for a single skill
22struct SyncResult {
23    skill: String,
24    entries_synced: usize,
25    entries_skipped: usize,
26    /// Whether sync succeeded (for partial failure handling)
27    success: bool,
28}
29
30/// Execute the sync command per [[RFC-0007:C-SYNC]].
31///
32/// Copies log entries from project-local fallback databases to primary runtime logs.
33pub fn sync(options: SyncOptions) -> Result<()> {
34    let project_dir = options
35        .project
36        .clone()
37        .or_else(|| env::current_dir().ok())
38        .ok_or_else(|| SkillcError::Internal("cannot determine project directory".to_string()))?;
39
40    verbose!("sync: project_dir={}", project_dir.display());
41
42    let logs_dir = crate::util::project_logs_dir(&project_dir);
43
44    // Determine which skills to sync
45    let skills: Vec<String> = if let Some(ref skill) = options.skill {
46        // Single skill specified — error if no logs exist (E040)
47        let skill_log_dir = logs_dir.join(skill);
48        let skill_db = skill_log_dir.join(".skillc-meta").join("logs.db");
49        if !skill_db.exists() {
50            return Err(SkillcError::NoLocalLogs);
51        }
52        vec![skill.clone()]
53    } else {
54        // All skills with fallback logs — informational if none
55        if !logs_dir.exists() {
56            println!("No local logs to sync");
57            return Ok(());
58        }
59        let found = list_skills_in_logs_dir(&logs_dir);
60        if found.is_empty() {
61            println!("No local logs to sync");
62            return Ok(());
63        }
64        found
65    };
66
67    verbose!("sync: found {} skill(s) to sync", skills.len());
68
69    let mut results = Vec::new();
70    let mut had_errors = false;
71
72    for skill in &skills {
73        match sync_skill(&logs_dir, skill, options.dry_run) {
74            Ok(result) => results.push(result),
75            Err(e) => {
76                // Partial failure: report error, continue with other skills
77                eprintln!("error: failed to sync '{}': {}", skill, e);
78                had_errors = true;
79                results.push(SyncResult {
80                    skill: skill.clone(),
81                    entries_synced: 0,
82                    entries_skipped: 0,
83                    success: false,
84                });
85            }
86        }
87    }
88
89    // Print results and purge successful syncs (SSOT: sync = move, not copy)
90    for result in &results {
91        if options.dry_run {
92            println!(
93                "Would sync {} entries for '{}'",
94                result.entries_synced, result.skill
95            );
96        } else if result.success {
97            println!(
98                "Synced {} entries for '{}' (local logs removed)",
99                result.entries_synced, result.skill
100            );
101            // Always delete local logs after successful sync
102            if let Err(e) = purge_local_logs(&logs_dir, &result.skill) {
103                eprintln!(
104                    "warning: failed to remove local logs for '{}': {}",
105                    result.skill, e
106                );
107            }
108        }
109        if result.entries_skipped > 0 {
110            verbose!(
111                "sync: skipped {} duplicate entries for '{}'",
112                result.entries_skipped,
113                result.skill
114            );
115        }
116    }
117
118    if had_errors {
119        // Return error if any skill failed (but we still synced what we could)
120        Err(SkillcError::Internal(
121            "some skills failed to sync".to_string(),
122        ))
123    } else {
124        Ok(())
125    }
126}
127
128/// List all skills with logs in the given logs directory.
129fn list_skills_in_logs_dir(logs_dir: &Path) -> Vec<String> {
130    let mut skills = Vec::new();
131
132    if let Ok(entries) = fs::read_dir(logs_dir) {
133        for entry in entries.flatten() {
134            if entry.path().is_dir() {
135                let db_path = entry.path().join(".skillc-meta").join("logs.db");
136                if db_path.exists()
137                    && let Some(name) = entry.file_name().to_str()
138                {
139                    skills.push(name.to_string());
140                }
141            }
142        }
143    }
144
145    skills.sort();
146    skills
147}
148
149/// Sync a single skill's logs from fallback to primary.
150fn sync_skill(logs_dir: &Path, skill: &str, dry_run: bool) -> Result<SyncResult> {
151    let fallback_dir = logs_dir.join(skill);
152    let fallback_db = fallback_dir.join(".skillc-meta").join("logs.db");
153
154    if !fallback_db.exists() {
155        return Ok(SyncResult {
156            skill: skill.to_string(),
157            entries_synced: 0,
158            entries_skipped: 0,
159            success: true,
160        });
161    }
162
163    // Open fallback (source) database
164    let src_conn = Connection::open(&fallback_db).map_err(|e| {
165        SkillcError::SyncSourceNotReadable(fallback_db.to_string_lossy().to_string(), e.to_string())
166    })?;
167
168    // Determine primary destination
169    let primary_dir = global_runtime_store()?.join(skill);
170    let primary_meta = primary_dir.join(".skillc-meta");
171    let primary_db = primary_meta.join("logs.db");
172
173    verbose!(
174        "sync: {} -> {}",
175        fallback_db.display(),
176        primary_db.display()
177    );
178
179    // Read all entries from source
180    let entries = read_log_entries(&src_conn)?;
181
182    if entries.is_empty() {
183        return Ok(SyncResult {
184            skill: skill.to_string(),
185            entries_synced: 0,
186            entries_skipped: 0,
187            success: true,
188        });
189    }
190
191    if dry_run {
192        // In dry run, just count entries (no dedup check)
193        return Ok(SyncResult {
194            skill: skill.to_string(),
195            entries_synced: entries.len(),
196            entries_skipped: 0,
197            success: false, // Don't purge on dry run
198        });
199    }
200
201    // Create destination directory if needed
202    fs::create_dir_all(&primary_meta).map_err(|e| {
203        SkillcError::SyncDestNotWritable(primary_db.to_string_lossy().to_string(), e.to_string())
204    })?;
205
206    // Open destination database
207    let dst_conn = Connection::open(&primary_db).map_err(|e| {
208        SkillcError::SyncDestNotWritable(primary_db.to_string_lossy().to_string(), e.to_string())
209    })?;
210
211    // Create schema if not exists
212    dst_conn
213        .execute(
214            "CREATE TABLE IF NOT EXISTS access_log (
215            id INTEGER PRIMARY KEY AUTOINCREMENT,
216            timestamp TEXT NOT NULL,
217            run_id TEXT NOT NULL,
218            command TEXT NOT NULL,
219            skill TEXT NOT NULL,
220            skill_path TEXT NOT NULL,
221            cwd TEXT NOT NULL,
222            args TEXT NOT NULL,
223            error TEXT
224        )",
225            [],
226        )
227        .map_err(|e| {
228            SkillcError::SyncDestNotWritable(
229                primary_db.to_string_lossy().to_string(),
230                e.to_string(),
231            )
232        })?;
233
234    // Insert entries with deduplication
235    let mut synced = 0;
236    let mut skipped = 0;
237
238    for entry in &entries {
239        if entry_exists(&dst_conn, entry)? {
240            skipped += 1;
241            continue;
242        }
243
244        insert_entry(&dst_conn, entry).map_err(|e| {
245            SkillcError::SyncDestNotWritable(
246                primary_db.to_string_lossy().to_string(),
247                e.to_string(),
248            )
249        })?;
250        synced += 1;
251    }
252
253    Ok(SyncResult {
254        skill: skill.to_string(),
255        entries_synced: synced,
256        entries_skipped: skipped,
257        success: true,
258    })
259}
260
261/// Log entry for sync operations
262struct LogEntryRow {
263    timestamp: String,
264    run_id: String,
265    command: String,
266    skill: String,
267    skill_path: String,
268    cwd: String,
269    args: String,
270    error: Option<String>,
271}
272
273/// Read all log entries from a database.
274fn read_log_entries(conn: &Connection) -> Result<Vec<LogEntryRow>> {
275    let mut stmt = conn
276        .prepare(
277            "SELECT timestamp, run_id, command, skill, skill_path, cwd, args, error
278         FROM access_log",
279        )
280        .map_err(|e| SkillcError::Internal(format!("failed to prepare query: {}", e)))?;
281
282    let rows = stmt
283        .query_map([], |row| {
284            Ok(LogEntryRow {
285                timestamp: row.get(0)?,
286                run_id: row.get(1)?,
287                command: row.get(2)?,
288                skill: row.get(3)?,
289                skill_path: row.get(4)?,
290                cwd: row.get(5)?,
291                args: row.get(6)?,
292                error: row.get(7)?,
293            })
294        })
295        .map_err(|e| SkillcError::Internal(format!("failed to query logs: {}", e)))?;
296
297    let mut entries = Vec::new();
298    for row in rows {
299        entries.push(row.map_err(|e| SkillcError::Internal(format!("failed to read row: {}", e)))?);
300    }
301
302    Ok(entries)
303}
304
305/// Check if an entry already exists in the destination database.
306/// Deduplication key: (run_id, timestamp, command, args)
307fn entry_exists(conn: &Connection, entry: &LogEntryRow) -> Result<bool> {
308    let count: i64 = conn
309        .query_row(
310            "SELECT COUNT(*) FROM access_log
311         WHERE run_id = ?1 AND timestamp = ?2 AND command = ?3 AND args = ?4",
312            rusqlite::params![entry.run_id, entry.timestamp, entry.command, entry.args],
313            |row| row.get(0),
314        )
315        .map_err(|e| SkillcError::Internal(format!("failed to check duplicate: {}", e)))?;
316
317    Ok(count > 0)
318}
319
320/// Insert an entry into the destination database.
321fn insert_entry(
322    conn: &Connection,
323    entry: &LogEntryRow,
324) -> std::result::Result<(), rusqlite::Error> {
325    conn.execute(
326        "INSERT INTO access_log (timestamp, run_id, command, skill, skill_path, cwd, args, error)
327         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
328        rusqlite::params![
329            entry.timestamp,
330            entry.run_id,
331            entry.command,
332            entry.skill,
333            entry.skill_path,
334            entry.cwd,
335            entry.args,
336            entry.error,
337        ],
338    )?;
339    Ok(())
340}
341
342/// Delete local fallback logs for a skill.
343fn purge_local_logs(logs_dir: &Path, skill: &str) -> Result<()> {
344    let skill_dir = logs_dir.join(skill);
345    if skill_dir.exists() {
346        fs::remove_dir_all(&skill_dir).map_err(|e| {
347            SkillcError::Internal(format!("failed to purge local logs for '{}': {}", skill, e))
348        })?;
349    }
350    Ok(())
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use tempfile::TempDir;
357
358    #[test]
359    fn test_list_skills_in_logs_dir_empty() {
360        let temp = TempDir::new().expect("failed to create temp dir");
361        let logs_dir = temp.path().join("logs");
362        fs::create_dir_all(&logs_dir).expect("failed to create logs dir");
363
364        let skills = list_skills_in_logs_dir(&logs_dir);
365        assert!(skills.is_empty());
366    }
367
368    #[test]
369    fn test_list_skills_in_logs_dir_with_skills() {
370        let temp = TempDir::new().expect("failed to create temp dir");
371        let logs_dir = temp.path().join("logs");
372
373        // Create skill directories with logs.db
374        for skill in ["rust", "cuda", "go"] {
375            let skill_dir = logs_dir.join(skill).join(".skillc-meta");
376            fs::create_dir_all(&skill_dir).expect("failed to create skill dir");
377            fs::write(skill_dir.join("logs.db"), b"").expect("failed to write logs.db");
378        }
379
380        let skills = list_skills_in_logs_dir(&logs_dir);
381        assert_eq!(skills, vec!["cuda", "go", "rust"]);
382    }
383
384    #[test]
385    fn test_list_skills_in_logs_dir_nonexistent() {
386        let skills = list_skills_in_logs_dir(Path::new("/nonexistent/path"));
387        assert!(skills.is_empty());
388    }
389
390    #[test]
391    fn test_list_skills_ignores_dirs_without_logs_db() {
392        let temp = TempDir::new().expect("failed to create temp dir");
393        let logs_dir = temp.path().join("logs");
394
395        // Create skill dir without logs.db
396        let skill_dir = logs_dir.join("incomplete-skill").join(".skillc-meta");
397        fs::create_dir_all(&skill_dir).expect("failed to create skill dir");
398        // No logs.db file
399
400        // Create skill dir with logs.db
401        let valid_skill_dir = logs_dir.join("valid-skill").join(".skillc-meta");
402        fs::create_dir_all(&valid_skill_dir).expect("failed to create skill dir");
403        fs::write(valid_skill_dir.join("logs.db"), b"").expect("failed to write logs.db");
404
405        let skills = list_skills_in_logs_dir(&logs_dir);
406        assert_eq!(skills, vec!["valid-skill"]);
407    }
408
409    fn create_test_logs_db(path: &Path) -> Connection {
410        fs::create_dir_all(path.parent().unwrap()).unwrap();
411        let conn = Connection::open(path).unwrap();
412        conn.execute(
413            "CREATE TABLE access_log (
414                id INTEGER PRIMARY KEY AUTOINCREMENT,
415                timestamp TEXT NOT NULL,
416                run_id TEXT NOT NULL,
417                command TEXT NOT NULL,
418                skill TEXT NOT NULL,
419                skill_path TEXT NOT NULL,
420                cwd TEXT NOT NULL,
421                args TEXT NOT NULL,
422                error TEXT
423            )",
424            [],
425        )
426        .unwrap();
427        conn
428    }
429
430    #[test]
431    fn test_read_log_entries_empty() {
432        let temp = TempDir::new().expect("failed to create temp dir");
433        let db_path = temp.path().join("logs.db");
434        let conn = create_test_logs_db(&db_path);
435
436        let entries = read_log_entries(&conn).unwrap();
437        assert!(entries.is_empty());
438    }
439
440    #[test]
441    fn test_read_log_entries_with_data() {
442        let temp = TempDir::new().expect("failed to create temp dir");
443        let db_path = temp.path().join("logs.db");
444        let conn = create_test_logs_db(&db_path);
445
446        conn.execute(
447            "INSERT INTO access_log (timestamp, run_id, command, skill, skill_path, cwd, args, error)
448             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
449            rusqlite::params![
450                "2025-01-01T00:00:00Z",
451                "run-123",
452                "show",
453                "test-skill",
454                "/path/to/skill",
455                "/cwd",
456                "--section Foo",
457                None::<String>
458            ],
459        )
460        .unwrap();
461
462        let entries = read_log_entries(&conn).unwrap();
463        assert_eq!(entries.len(), 1);
464        assert_eq!(entries[0].run_id, "run-123");
465        assert_eq!(entries[0].command, "show");
466    }
467
468    #[test]
469    fn test_entry_exists() {
470        let temp = TempDir::new().expect("failed to create temp dir");
471        let db_path = temp.path().join("logs.db");
472        let conn = create_test_logs_db(&db_path);
473
474        let entry = LogEntryRow {
475            timestamp: "2025-01-01T00:00:00Z".to_string(),
476            run_id: "run-123".to_string(),
477            command: "show".to_string(),
478            skill: "test-skill".to_string(),
479            skill_path: "/path/to/skill".to_string(),
480            cwd: "/cwd".to_string(),
481            args: "--section Foo".to_string(),
482            error: None,
483        };
484
485        // Entry doesn't exist yet
486        assert!(!entry_exists(&conn, &entry).unwrap());
487
488        // Insert entry
489        insert_entry(&conn, &entry).unwrap();
490
491        // Now it exists
492        assert!(entry_exists(&conn, &entry).unwrap());
493    }
494
495    #[test]
496    fn test_sync_skill_empty_db() {
497        let temp = TempDir::new().expect("failed to create temp dir");
498        let logs_dir = temp.path().join("logs");
499
500        // Create empty logs database
501        let skill_dir = logs_dir.join("test-skill").join(".skillc-meta");
502        create_test_logs_db(&skill_dir.join("logs.db"));
503
504        let result = sync_skill(&logs_dir, "test-skill", true).unwrap();
505        assert_eq!(result.skill, "test-skill");
506        assert_eq!(result.entries_synced, 0);
507        assert!(result.success); // Empty is success
508    }
509
510    #[test]
511    fn test_sync_skill_nonexistent() {
512        let temp = TempDir::new().expect("failed to create temp dir");
513        let logs_dir = temp.path().join("logs");
514        fs::create_dir_all(&logs_dir).unwrap();
515
516        let result = sync_skill(&logs_dir, "nonexistent", true).unwrap();
517        assert_eq!(result.entries_synced, 0);
518        assert!(result.success);
519    }
520
521    #[test]
522    fn test_purge_local_logs() {
523        let temp = TempDir::new().expect("failed to create temp dir");
524        let logs_dir = temp.path().join("logs");
525        let skill_dir = logs_dir.join("test-skill");
526        fs::create_dir_all(&skill_dir).unwrap();
527        fs::write(skill_dir.join("test.txt"), b"test").unwrap();
528
529        assert!(skill_dir.exists());
530        purge_local_logs(&logs_dir, "test-skill").unwrap();
531        assert!(!skill_dir.exists());
532    }
533
534    #[test]
535    fn test_purge_local_logs_nonexistent() {
536        let temp = TempDir::new().expect("failed to create temp dir");
537        // Should not error on nonexistent
538        purge_local_logs(temp.path(), "nonexistent").unwrap();
539    }
540}