1use 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
11pub struct SyncOptions {
13 pub skill: Option<String>,
15 pub project: Option<PathBuf>,
17 pub dry_run: bool,
19}
20
21struct SyncResult {
23 skill: String,
24 entries_synced: usize,
25 entries_skipped: usize,
26 success: bool,
28}
29
30pub 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 let skills: Vec<String> = if let Some(ref skill) = options.skill {
46 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 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 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 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 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 Err(SkillcError::Internal(
121 "some skills failed to sync".to_string(),
122 ))
123 } else {
124 Ok(())
125 }
126}
127
128fn 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
149fn 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 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 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 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 return Ok(SyncResult {
194 skill: skill.to_string(),
195 entries_synced: entries.len(),
196 entries_skipped: 0,
197 success: false, });
199 }
200
201 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 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 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 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
261struct 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
273fn 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
305fn 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
320fn 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
342fn 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 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 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 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 assert!(!entry_exists(&conn, &entry).unwrap());
487
488 insert_entry(&conn, &entry).unwrap();
490
491 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 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); }
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 purge_local_logs(temp.path(), "nonexistent").unwrap();
539 }
540}