1use crate::config::IdsConfig;
25use crate::export::Snapshot;
26use anyhow::{Context, Result, anyhow};
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29use std::collections::{HashMap, HashSet};
30use std::path::Path;
31
32use super::Database;
33use super::import::{ImportMode, ImportOptions, ImportResult, remap_snapshot};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct TemplateMetadata {
38 pub name: String,
40
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub source_path: Option<String>,
44
45 pub entry_points: Vec<String>,
48
49 pub exit_points: Vec<String>,
52
53 pub task_count: usize,
55
56 pub dependency_count: usize,
58
59 pub all_tags: Vec<String>,
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct InstantiateOptions {
66 pub parent_task_id: Option<String>,
69
70 pub attach_dep_type: String,
72
73 pub title_prefix: Option<String>,
75
76 pub extra_tags: Vec<String>,
78
79 pub reset_status: bool,
82
83 pub initial_status: Option<String>,
86}
87
88impl InstantiateOptions {
89 pub fn new() -> Self {
91 Self {
92 attach_dep_type: "contains".to_string(),
93 reset_status: true,
94 ..Default::default()
95 }
96 }
97
98 pub fn with_parent(mut self, parent_id: &str) -> Self {
100 self.parent_task_id = Some(parent_id.to_string());
101 self
102 }
103
104 pub fn with_title_prefix(mut self, prefix: &str) -> Self {
106 self.title_prefix = Some(prefix.to_string());
107 self
108 }
109
110 pub fn with_extra_tags(mut self, tags: Vec<String>) -> Self {
112 self.extra_tags = tags;
113 self
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct InstantiateResult {
120 pub metadata: TemplateMetadata,
122
123 pub id_map: HashMap<String, String>,
125
126 pub entry_point_ids: Vec<String>,
128
129 pub exit_point_ids: Vec<String>,
131
132 pub import_stats: ImportStats,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub attached_to_parent: Option<String>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct ImportStats {
143 pub tasks_imported: usize,
144 pub dependencies_imported: usize,
145 pub tags_imported: usize,
146 pub total_rows: usize,
147}
148
149impl From<&ImportResult> for ImportStats {
150 fn from(result: &ImportResult) -> Self {
151 Self {
152 tasks_imported: *result.rows_imported.get("tasks").unwrap_or(&0),
153 dependencies_imported: *result.rows_imported.get("dependencies").unwrap_or(&0),
154 tags_imported: result.rows_imported.get("task_tags").unwrap_or(&0)
155 + result.rows_imported.get("task_needed_tags").unwrap_or(&0)
156 + result.rows_imported.get("task_wanted_tags").unwrap_or(&0),
157 total_rows: result.total_rows(),
158 }
159 }
160}
161
162pub fn analyze_template(
172 snapshot: &Snapshot,
173 name: &str,
174 source_path: Option<&str>,
175) -> Result<TemplateMetadata> {
176 let task_ids: HashSet<String> = snapshot
178 .tables
179 .get("tasks")
180 .map(|tasks| {
181 tasks
182 .iter()
183 .filter_map(|t| t.get("id").and_then(|v| v.as_str()).map(String::from))
184 .collect()
185 })
186 .unwrap_or_default();
187
188 if task_ids.is_empty() {
189 return Err(anyhow!("Template contains no tasks"));
190 }
191
192 let mut child_task_ids: HashSet<String> = HashSet::new();
195 let mut exit_point_ids: HashSet<String> = HashSet::new();
196
197 if let Some(deps) = snapshot.tables.get("dependencies") {
198 for dep in deps {
199 let from_id = dep
200 .get("from_task_id")
201 .and_then(|v| v.as_str())
202 .unwrap_or("");
203 let to_id = dep.get("to_task_id").and_then(|v| v.as_str()).unwrap_or("");
204 let dep_type = dep.get("dep_type").and_then(|v| v.as_str()).unwrap_or("");
205
206 if dep_type == "contains" && task_ids.contains(from_id) && task_ids.contains(to_id) {
208 child_task_ids.insert(to_id.to_string());
209 }
210
211 if !task_ids.contains(from_id) || !task_ids.contains(to_id) {
213 if task_ids.contains(from_id) {
215 exit_point_ids.insert(from_id.to_string());
216 }
217 if task_ids.contains(to_id) {
218 exit_point_ids.insert(to_id.to_string());
219 }
220 }
221 }
222 }
223
224 let entry_points: Vec<String> = task_ids
226 .iter()
227 .filter(|id| !child_task_ids.contains(*id))
228 .cloned()
229 .collect();
230
231 let exit_points: Vec<String> = exit_point_ids.into_iter().collect();
232
233 let mut all_tags: HashSet<String> = HashSet::new();
235 if let Some(tags) = snapshot.tables.get("task_tags") {
236 for tag_row in tags {
237 if let Some(tag) = tag_row.get("tag").and_then(|v| v.as_str()) {
238 all_tags.insert(tag.to_string());
239 }
240 }
241 }
242 let mut all_tags: Vec<String> = all_tags.into_iter().collect();
243 all_tags.sort();
244
245 let dependency_count = snapshot
246 .tables
247 .get("dependencies")
248 .map(|d| d.len())
249 .unwrap_or(0);
250
251 Ok(TemplateMetadata {
252 name: name.to_string(),
253 source_path: source_path.map(String::from),
254 entry_points,
255 exit_points,
256 task_count: task_ids.len(),
257 dependency_count,
258 all_tags,
259 })
260}
261
262fn prepare_snapshot(
273 snapshot: &Snapshot,
274 ids_config: &IdsConfig,
275 options: &InstantiateOptions,
276) -> Result<(Snapshot, HashMap<String, String>)> {
277 let (mut prepared, id_map) =
279 remap_snapshot(snapshot, ids_config).context("Failed to remap template IDs")?;
280
281 let now_ms = chrono::Utc::now().timestamp_millis();
282
283 if let Some(tasks) = prepared.tables.get_mut("tasks") {
285 for task_row in tasks.iter_mut() {
286 if let Some(obj) = task_row.as_object_mut() {
287 if options.reset_status {
289 let status = options.initial_status.as_deref().unwrap_or("pending");
290 obj.insert("status".to_string(), Value::String(status.to_string()));
291 }
292
293 if let Some(ref prefix) = options.title_prefix
295 && let Some(title) = obj.get("title").and_then(|v| v.as_str())
296 {
297 obj.insert(
298 "title".to_string(),
299 Value::String(format!("{}: {}", prefix, title)),
300 );
301 }
302
303 obj.insert("worker_id".to_string(), Value::Null);
305 obj.insert("claimed_at".to_string(), Value::Null);
306 obj.insert("current_thought".to_string(), Value::Null);
307 obj.insert("started_at".to_string(), Value::Null);
308 obj.insert("completed_at".to_string(), Value::Null);
309 obj.insert("time_actual_ms".to_string(), Value::Null);
310 obj.insert("cost_usd".to_string(), serde_json::json!(0.0));
311 obj.insert(
312 "metrics".to_string(),
313 serde_json::json!([0, 0, 0, 0, 0, 0, 0, 0]),
314 );
315
316 obj.insert("created_at".to_string(), serde_json::json!(now_ms));
318 obj.insert("updated_at".to_string(), serde_json::json!(now_ms));
319 }
320 }
321 }
322
323 if !options.extra_tags.is_empty() {
325 let task_ids: Vec<String> = prepared
327 .tables
328 .get("tasks")
329 .map(|tasks| {
330 tasks
331 .iter()
332 .filter_map(|t| t.get("id").and_then(|v| v.as_str()).map(String::from))
333 .collect()
334 })
335 .unwrap_or_default();
336
337 let tag_rows = prepared
339 .tables
340 .entry("task_tags".to_string())
341 .or_insert_with(Vec::new);
342
343 for task_id in &task_ids {
344 for tag in &options.extra_tags {
345 tag_rows.push(serde_json::json!({
346 "task_id": task_id,
347 "tag": tag,
348 }));
349 }
350 }
351 }
352
353 prepared
355 .tables
356 .insert("task_sequence".to_string(), Vec::new());
357
358 Ok((prepared, id_map))
359}
360
361pub fn list_templates(templates_dir: &Path) -> Result<Vec<TemplateMetadata>> {
366 let mut templates = Vec::new();
367
368 if !templates_dir.exists() {
369 return Ok(templates);
370 }
371
372 let entries = std::fs::read_dir(templates_dir)
373 .with_context(|| format!("Failed to read templates directory: {:?}", templates_dir))?;
374
375 for entry in entries {
376 let entry = entry?;
377 let path = entry.path();
378
379 if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
381 continue;
382 }
383
384 match Snapshot::from_file(&path) {
386 Ok(snapshot) => {
387 let name = path
388 .file_stem()
389 .and_then(|s| s.to_str())
390 .unwrap_or("unknown")
391 .to_string();
392
393 match analyze_template(&snapshot, &name, path.to_str()) {
394 Ok(metadata) => templates.push(metadata),
395 Err(e) => {
396 eprintln!("Warning: Template {:?} has invalid structure: {}", path, e);
398 }
399 }
400 }
401 Err(e) => {
402 eprintln!("Warning: Failed to load template {:?}: {}", path, e);
403 }
404 }
405 }
406
407 templates.sort_by(|a, b| a.name.cmp(&b.name));
409
410 Ok(templates)
411}
412
413impl Database {
414 pub fn instantiate_template(
434 &self,
435 snapshot: &Snapshot,
436 name: &str,
437 source_path: Option<&str>,
438 ids_config: &IdsConfig,
439 options: &InstantiateOptions,
440 ) -> Result<InstantiateResult> {
441 let metadata = analyze_template(snapshot, name, source_path)?;
443
444 if let Some(ref parent_id) = options.parent_task_id
446 && !self.task_exists(parent_id)?
447 {
448 return Err(anyhow!(
449 "Parent task '{}' not found. Cannot attach template.",
450 parent_id
451 ));
452 }
453
454 let (prepared_snapshot, id_map) = prepare_snapshot(snapshot, ids_config, options)?;
456
457 let entry_point_ids: Vec<String> = metadata
459 .entry_points
460 .iter()
461 .filter_map(|old_id| id_map.get(old_id).cloned())
462 .collect();
463
464 let exit_point_ids: Vec<String> = metadata
465 .exit_points
466 .iter()
467 .filter_map(|old_id| id_map.get(old_id).cloned())
468 .collect();
469
470 let import_options = ImportOptions {
472 mode: ImportMode::Merge,
473 remap_ids: false,
474 parent_id: None,
475 };
476 let import_result = self
477 .import_snapshot(&prepared_snapshot, &import_options)
478 .context("Failed to import instantiated template")?;
479
480 let import_stats = ImportStats::from(&import_result);
481
482 if let Some(ref parent_id) = options.parent_task_id {
484 self.attach_template_to_parent(parent_id, &entry_point_ids, &options.attach_dep_type)?;
485 }
486
487 Ok(InstantiateResult {
488 metadata,
489 id_map,
490 entry_point_ids,
491 exit_point_ids,
492 import_stats,
493 attached_to_parent: options.parent_task_id.clone(),
494 })
495 }
496
497 pub fn instantiate_template_file(
502 &self,
503 template_path: &Path,
504 ids_config: &IdsConfig,
505 options: &InstantiateOptions,
506 ) -> Result<InstantiateResult> {
507 let snapshot = Snapshot::from_file(template_path)
508 .with_context(|| format!("Failed to load template from {:?}", template_path))?;
509
510 let name = template_path
511 .file_stem()
512 .and_then(|s| s.to_str())
513 .unwrap_or("unknown")
514 .to_string();
515
516 self.instantiate_template(
517 &snapshot,
518 &name,
519 template_path.to_str(),
520 ids_config,
521 options,
522 )
523 }
524
525 fn attach_template_to_parent(
530 &self,
531 parent_id: &str,
532 entry_point_ids: &[String],
533 dep_type: &str,
534 ) -> Result<()> {
535 self.with_conn(|conn| {
536 for entry_id in entry_point_ids {
537 conn.execute(
538 "INSERT OR IGNORE INTO dependencies (from_task_id, to_task_id, dep_type) VALUES (?1, ?2, ?3)",
539 rusqlite::params![parent_id, entry_id, dep_type],
540 )?;
541 }
542 Ok(())
543 })
544 }
545}
546
547#[cfg(test)]
548mod tests {
549 use super::*;
550 use crate::config::IdsConfig;
551 use crate::export::{CURRENT_SCHEMA_VERSION, EXPORT_VERSION, Snapshot};
552 use std::collections::BTreeMap;
553
554 fn make_test_template() -> Snapshot {
556 let mut tables = BTreeMap::new();
557
558 tables.insert(
560 "tasks".to_string(),
561 vec![
562 serde_json::json!({
563 "id": "tpl-root",
564 "title": "Root Task",
565 "description": "The root of the template",
566 "status": "pending",
567 "priority": "5",
568 "worker_id": null,
569 "claimed_at": null,
570 "needed_tags": [],
571 "wanted_tags": [],
572 "tags": ["template"],
573 "points": null,
574 "time_estimate_ms": null,
575 "time_actual_ms": null,
576 "started_at": null,
577 "completed_at": null,
578 "current_thought": null,
579 "cost_usd": 0.0,
580 "metrics": [0,0,0,0,0,0,0,0],
581 "created_at": 1000000,
582 "updated_at": 1000000
583 }),
584 serde_json::json!({
585 "id": "tpl-child-1",
586 "title": "Child Task 1",
587 "description": "First child",
588 "status": "pending",
589 "priority": "5",
590 "worker_id": null,
591 "claimed_at": null,
592 "needed_tags": [],
593 "wanted_tags": [],
594 "tags": ["child"],
595 "points": 3,
596 "time_estimate_ms": null,
597 "time_actual_ms": null,
598 "started_at": null,
599 "completed_at": null,
600 "current_thought": null,
601 "cost_usd": 0.0,
602 "metrics": [0,0,0,0,0,0,0,0],
603 "created_at": 1000001,
604 "updated_at": 1000001
605 }),
606 ],
607 );
608
609 tables.insert(
611 "dependencies".to_string(),
612 vec![serde_json::json!({
613 "from_task_id": "tpl-root",
614 "to_task_id": "tpl-child-1",
615 "dep_type": "contains"
616 })],
617 );
618
619 tables.insert(
620 "task_tags".to_string(),
621 vec![
622 serde_json::json!({"task_id": "tpl-root", "tag": "template"}),
623 serde_json::json!({"task_id": "tpl-child-1", "tag": "child"}),
624 ],
625 );
626
627 tables.insert("attachments".to_string(), Vec::new());
628 tables.insert("task_needed_tags".to_string(), Vec::new());
629 tables.insert("task_wanted_tags".to_string(), Vec::new());
630 tables.insert("task_sequence".to_string(), Vec::new());
631
632 Snapshot {
633 schema_version: CURRENT_SCHEMA_VERSION,
634 export_version: EXPORT_VERSION.to_string(),
635 exported_at: chrono::Utc::now().to_rfc3339(),
636 exported_by: "test-template".to_string(),
637 tables,
638 }
639 }
640
641 #[test]
642 fn test_analyze_template_entry_points() {
643 let snapshot = make_test_template();
644 let metadata = analyze_template(&snapshot, "test-template", None).unwrap();
645
646 assert_eq!(metadata.entry_points.len(), 1);
648 assert!(metadata.entry_points.contains(&"tpl-root".to_string()));
649 assert_eq!(metadata.task_count, 2);
650 assert_eq!(metadata.dependency_count, 1);
651 }
652
653 #[test]
654 fn test_analyze_template_exit_points() {
655 let mut snapshot = make_test_template();
656
657 if let Some(deps) = snapshot.tables.get_mut("dependencies") {
659 deps.push(serde_json::json!({
660 "from_task_id": "tpl-child-1",
661 "to_task_id": "external-task-123",
662 "dep_type": "blocks"
663 }));
664 }
665
666 let metadata = analyze_template(&snapshot, "test-template", None).unwrap();
667
668 assert!(metadata.exit_points.contains(&"tpl-child-1".to_string()));
670 }
671
672 #[test]
673 fn test_analyze_empty_template() {
674 let snapshot = Snapshot {
675 schema_version: CURRENT_SCHEMA_VERSION,
676 export_version: EXPORT_VERSION.to_string(),
677 exported_at: chrono::Utc::now().to_rfc3339(),
678 exported_by: "test".to_string(),
679 tables: BTreeMap::new(),
680 };
681
682 let result = analyze_template(&snapshot, "empty", None);
683 assert!(result.is_err());
684 assert!(result.unwrap_err().to_string().contains("no tasks"));
685 }
686
687 #[test]
688 fn test_prepare_snapshot_remaps_ids() {
689 let snapshot = make_test_template();
690 let ids_config = IdsConfig::default();
691 let options = InstantiateOptions::new();
692
693 let (prepared, id_map) = prepare_snapshot(&snapshot, &ids_config, &options).unwrap();
694
695 assert!(id_map.contains_key("tpl-root"));
697 assert!(id_map.contains_key("tpl-child-1"));
698
699 assert_ne!(id_map["tpl-root"], "tpl-root");
701 assert_ne!(id_map["tpl-child-1"], "tpl-child-1");
702
703 let tasks = prepared.tables.get("tasks").unwrap();
705 let task_ids: Vec<&str> = tasks
706 .iter()
707 .filter_map(|t| t.get("id").and_then(|v| v.as_str()))
708 .collect();
709 assert!(!task_ids.contains(&"tpl-root"));
710 assert!(task_ids.contains(&id_map["tpl-root"].as_str()));
711 }
712
713 #[test]
714 fn test_prepare_snapshot_resets_status() {
715 let mut snapshot = make_test_template();
716
717 if let Some(tasks) = snapshot.tables.get_mut("tasks") {
719 for task in tasks.iter_mut() {
720 if let Some(obj) = task.as_object_mut() {
721 obj.insert("status".to_string(), Value::String("completed".to_string()));
722 obj.insert(
723 "worker_id".to_string(),
724 Value::String("old-worker".to_string()),
725 );
726 }
727 }
728 }
729
730 let ids_config = IdsConfig::default();
731 let options = InstantiateOptions::new(); let (prepared, _) = prepare_snapshot(&snapshot, &ids_config, &options).unwrap();
734
735 let tasks = prepared.tables.get("tasks").unwrap();
737 for task in tasks {
738 assert_eq!(task.get("status").and_then(|v| v.as_str()), Some("pending"));
739 assert!(task.get("worker_id").unwrap().is_null());
741 assert!(task.get("claimed_at").unwrap().is_null());
742 }
743 }
744
745 #[test]
746 fn test_prepare_snapshot_title_prefix() {
747 let snapshot = make_test_template();
748 let ids_config = IdsConfig::default();
749 let options = InstantiateOptions::new().with_title_prefix("Sprint-1");
750
751 let (prepared, _) = prepare_snapshot(&snapshot, &ids_config, &options).unwrap();
752
753 let tasks = prepared.tables.get("tasks").unwrap();
754 for task in tasks {
755 let title = task.get("title").and_then(|v| v.as_str()).unwrap();
756 assert!(
757 title.starts_with("Sprint-1: "),
758 "Title should be prefixed: {}",
759 title
760 );
761 }
762 }
763
764 #[test]
765 fn test_prepare_snapshot_extra_tags() {
766 let snapshot = make_test_template();
767 let ids_config = IdsConfig::default();
768 let options =
769 InstantiateOptions::new().with_extra_tags(vec!["sprint-1".into(), "team-a".into()]);
770
771 let (prepared, _) = prepare_snapshot(&snapshot, &ids_config, &options).unwrap();
772
773 let tags = prepared.tables.get("task_tags").unwrap();
775 assert!(
777 tags.len() >= 6,
778 "Expected at least 6 tag rows, got {}",
779 tags.len()
780 );
781 }
782
783 #[test]
784 fn test_prepare_snapshot_clears_sequence() {
785 let mut snapshot = make_test_template();
786
787 snapshot.tables.insert(
789 "task_sequence".to_string(),
790 vec![serde_json::json!({
791 "id": 1,
792 "task_id": "tpl-root",
793 "worker_id": "old-worker",
794 "status": "working",
795 "phase": null,
796 "reason": "started",
797 "timestamp": 1000000,
798 "end_timestamp": 1000100
799 })],
800 );
801
802 let ids_config = IdsConfig::default();
803 let options = InstantiateOptions::new();
804
805 let (prepared, _) = prepare_snapshot(&snapshot, &ids_config, &options).unwrap();
806
807 let sequence = prepared.tables.get("task_sequence").unwrap();
809 assert!(
810 sequence.is_empty(),
811 "task_sequence should be empty after instantiation"
812 );
813 }
814
815 #[test]
816 fn test_instantiate_template_integration() {
817 let db = Database::open_in_memory().unwrap();
818 let snapshot = make_test_template();
819 let ids_config = IdsConfig::default();
820 let options = InstantiateOptions::new();
821
822 let result = db
823 .instantiate_template(&snapshot, "test-template", None, &ids_config, &options)
824 .unwrap();
825
826 assert_eq!(result.metadata.name, "test-template");
828 assert_eq!(result.metadata.task_count, 2);
829
830 assert_eq!(result.entry_point_ids.len(), 1);
832
833 assert_eq!(result.import_stats.tasks_imported, 2);
835 assert_eq!(result.import_stats.dependencies_imported, 1);
836
837 assert_eq!(result.id_map.len(), 2);
839
840 for new_id in result.id_map.values() {
842 assert!(
843 db.task_exists(new_id).unwrap(),
844 "Task {} should exist",
845 new_id
846 );
847 }
848 }
849
850 #[test]
851 fn test_instantiate_with_parent() {
852 let db = Database::open_in_memory().unwrap();
853
854 db.with_conn(|conn| {
856 conn.execute(
857 "INSERT INTO tasks (id, title, status, priority, cost_usd, created_at, updated_at)
858 VALUES ('parent-task', 'Parent', 'pending', 5, 0.0, 1000000, 1000000)",
859 [],
860 )?;
861 Ok(())
862 })
863 .unwrap();
864
865 let snapshot = make_test_template();
866 let ids_config = IdsConfig::default();
867 let options = InstantiateOptions::new().with_parent("parent-task");
868
869 let result = db
870 .instantiate_template(&snapshot, "test-template", None, &ids_config, &options)
871 .unwrap();
872
873 assert_eq!(result.attached_to_parent, Some("parent-task".to_string()));
875
876 let has_dep: bool = db
878 .with_conn(|conn| {
879 conn.query_row(
880 "SELECT 1 FROM dependencies WHERE from_task_id = 'parent-task' AND dep_type = 'contains'",
881 [],
882 |_| Ok(true),
883 )
884 .map_err(|e| anyhow::anyhow!("{}", e))
885 })
886 .unwrap_or(false);
887 assert!(
888 has_dep,
889 "Parent should have a contains dependency to entry point"
890 );
891 }
892
893 #[test]
894 fn test_instantiate_with_invalid_parent() {
895 let db = Database::open_in_memory().unwrap();
896 let snapshot = make_test_template();
897 let ids_config = IdsConfig::default();
898 let options = InstantiateOptions::new().with_parent("nonexistent-parent");
899
900 let result =
901 db.instantiate_template(&snapshot, "test-template", None, &ids_config, &options);
902 assert!(result.is_err());
903 assert!(result.unwrap_err().to_string().contains("not found"));
904 }
905
906 #[test]
907 fn test_multiple_instantiations_unique_ids() {
908 let db = Database::open_in_memory().unwrap();
909 let snapshot = make_test_template();
910 let ids_config = IdsConfig::default();
911 let options = InstantiateOptions::new();
912
913 let result1 = db
915 .instantiate_template(&snapshot, "test-1", None, &ids_config, &options)
916 .unwrap();
917 let result2 = db
918 .instantiate_template(&snapshot, "test-2", None, &ids_config, &options)
919 .unwrap();
920
921 let ids1: HashSet<&String> = result1.id_map.values().collect();
923 let ids2: HashSet<&String> = result2.id_map.values().collect();
924
925 assert!(
926 ids1.is_disjoint(&ids2),
927 "Multiple instantiations should produce unique IDs"
928 );
929
930 assert_eq!(result1.import_stats.tasks_imported, 2);
932 assert_eq!(result2.import_stats.tasks_imported, 2);
933 }
934}