Skip to main content

task_graph_mcp/db/
template.rs

1//! Template instantiation system for the task-graph database.
2//!
3//! Templates are Snapshot-format JSON files that define reusable task structures.
4//! Instantiation creates fresh copies with new IDs while preserving the internal
5//! dependency graph, optionally attaching the template root to a parent task.
6//!
7//! # Entry and Exit Points
8//!
9//! - **Entry points**: Root tasks within the template (tasks that have no parent
10//!   within the template itself). These are the "top" of the template hierarchy.
11//! - **Exit points**: Tasks with external dependency targets (references to task IDs
12//!   not present in the template). These represent integration boundaries.
13//!
14//! # Instantiation Flow
15//!
16//! 1. Load the template from a Snapshot JSON file
17//! 2. Validate schema compatibility
18//! 3. Detect entry/exit points from the template structure
19//! 4. Remap all IDs to fresh petname-based IDs
20//! 5. Record template metadata (source, original IDs, mapping)
21//! 6. Optionally attach entry points to a parent task
22//! 7. Import into the database via merge mode
23
24use 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/// Metadata about a template, extracted during analysis.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct TemplateMetadata {
38    /// Name of the template (derived from filename or explicit).
39    pub name: String,
40
41    /// Source file path the template was loaded from.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub source_path: Option<String>,
44
45    /// Entry point task IDs (root tasks with no parent in the template).
46    /// These are the original IDs from the template file.
47    pub entry_points: Vec<String>,
48
49    /// Exit point task IDs (tasks with external dependency references).
50    /// These are the original IDs from the template file.
51    pub exit_points: Vec<String>,
52
53    /// Total number of tasks in the template.
54    pub task_count: usize,
55
56    /// Total number of dependencies in the template.
57    pub dependency_count: usize,
58
59    /// Tags found across all tasks in the template.
60    pub all_tags: Vec<String>,
61}
62
63/// Options for controlling template instantiation.
64#[derive(Debug, Clone, Default)]
65pub struct InstantiateOptions {
66    /// Parent task ID to attach the template's entry points to.
67    /// A "contains" dependency will be created from parent to each entry point.
68    pub parent_task_id: Option<String>,
69
70    /// Dependency type to use when attaching to parent (default: "contains").
71    pub attach_dep_type: String,
72
73    /// Optional prefix to add to task titles for disambiguation.
74    pub title_prefix: Option<String>,
75
76    /// Additional tags to add to all instantiated tasks.
77    pub extra_tags: Vec<String>,
78
79    /// Whether to reset all task statuses to the initial state.
80    /// Default: true (templates are instantiated as fresh work).
81    pub reset_status: bool,
82
83    /// Override the initial status for instantiated tasks.
84    /// If None, uses the config's initial state.
85    pub initial_status: Option<String>,
86}
87
88impl InstantiateOptions {
89    /// Create default instantiation options.
90    pub fn new() -> Self {
91        Self {
92            attach_dep_type: "contains".to_string(),
93            reset_status: true,
94            ..Default::default()
95        }
96    }
97
98    /// Set the parent task ID (builder pattern).
99    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    /// Set a title prefix (builder pattern).
105    pub fn with_title_prefix(mut self, prefix: &str) -> Self {
106        self.title_prefix = Some(prefix.to_string());
107        self
108    }
109
110    /// Add extra tags to all instantiated tasks (builder pattern).
111    pub fn with_extra_tags(mut self, tags: Vec<String>) -> Self {
112        self.extra_tags = tags;
113        self
114    }
115}
116
117/// Result of a template instantiation operation.
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct InstantiateResult {
120    /// Template metadata (from the source template).
121    pub metadata: TemplateMetadata,
122
123    /// ID remapping table: original template ID -> new database ID.
124    pub id_map: HashMap<String, String>,
125
126    /// New IDs of the entry point tasks (after remapping).
127    pub entry_point_ids: Vec<String>,
128
129    /// New IDs of the exit point tasks (after remapping).
130    pub exit_point_ids: Vec<String>,
131
132    /// Import statistics from the database insertion.
133    pub import_stats: ImportStats,
134
135    /// Parent task ID if attachment was performed.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub attached_to_parent: Option<String>,
138}
139
140/// Simplified import stats for the instantiation result.
141#[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
162/// Analyze a template snapshot to extract metadata without modifying it.
163///
164/// This identifies entry points (root tasks), exit points (external references),
165/// and collects summary statistics about the template.
166///
167/// # Arguments
168/// * `snapshot` - The template snapshot to analyze
169/// * `name` - Template name (for metadata)
170/// * `source_path` - Optional source file path
171pub fn analyze_template(
172    snapshot: &Snapshot,
173    name: &str,
174    source_path: Option<&str>,
175) -> Result<TemplateMetadata> {
176    // Collect all task IDs in the template
177    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    // Find which tasks are children (have a parent within the template)
193    // A task is a child if it appears as to_task_id in a vertical (contains) dependency
194    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            // Track children (tasks contained by other template tasks)
207            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            // Track exit points (dependencies that reference tasks outside the template)
212            if !task_ids.contains(from_id) || !task_ids.contains(to_id) {
213                // The task that IS in the template is an exit point
214                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    // Entry points are tasks that are not children of any other template task
225    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    // Collect all unique tags
234    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
262/// Prepare a template snapshot for instantiation by remapping IDs and optionally
263/// modifying task properties. Returns the prepared snapshot and ID mapping.
264///
265/// This is the core transformation step:
266/// 1. Remap all task IDs to fresh petname IDs
267/// 2. Optionally reset task statuses to initial state
268/// 3. Optionally prefix task titles
269/// 4. Optionally add extra tags
270/// 5. Reset timestamps to current time
271/// 6. Clear runtime fields (worker_id, claimed_at, thoughts, etc.)
272fn prepare_snapshot(
273    snapshot: &Snapshot,
274    ids_config: &IdsConfig,
275    options: &InstantiateOptions,
276) -> Result<(Snapshot, HashMap<String, String>)> {
277    // Phase 1: Remap all IDs using the existing remap_snapshot function
278    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    // Phase 2: Apply template instantiation transformations
284    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                // Reset status if requested
288                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                // Prefix titles if requested
294                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                // Clear runtime fields
304                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                // Update timestamps to now
317                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    // Phase 3: Add extra tags if requested
324    if !options.extra_tags.is_empty() {
325        // Get all task IDs from the prepared (remapped) snapshot
326        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        // Add extra tags for each task
338        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    // Phase 4: Clear task_sequence (state history is not relevant for instantiated templates)
354    prepared
355        .tables
356        .insert("task_sequence".to_string(), Vec::new());
357
358    Ok((prepared, id_map))
359}
360
361/// List available templates from a directory.
362///
363/// Scans the given directory for .json files that are valid Snapshot-format templates.
364/// Returns metadata for each discovered template.
365pub 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        // Only process .json files
380        if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
381            continue;
382        }
383
384        // Try to load and analyze the template
385        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                        // Skip invalid templates but log a warning
397                        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    // Sort by name for deterministic ordering
408    templates.sort_by(|a, b| a.name.cmp(&b.name));
409
410    Ok(templates)
411}
412
413impl Database {
414    /// Instantiate a template from a Snapshot into the database.
415    ///
416    /// This is the main entry point for template instantiation:
417    /// 1. Analyzes the template to detect entry/exit points
418    /// 2. Remaps all IDs to fresh petname-based IDs
419    /// 3. Applies instantiation transformations (status reset, title prefix, etc.)
420    /// 4. Imports the prepared snapshot via merge mode
421    /// 5. Optionally attaches entry points to a parent task
422    ///
423    /// # Arguments
424    /// * `snapshot` - The template snapshot to instantiate
425    /// * `name` - Template name (for metadata/tracking)
426    /// * `source_path` - Optional source file path
427    /// * `ids_config` - ID generation configuration
428    /// * `options` - Instantiation options
429    ///
430    /// # Returns
431    /// * `Ok(InstantiateResult)` - Instantiation results including ID mapping
432    /// * `Err` - If instantiation fails
433    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        // Step 1: Analyze the template to find entry/exit points
442        let metadata = analyze_template(snapshot, name, source_path)?;
443
444        // Step 2: Validate parent task exists if specified
445        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        // Step 3: Prepare the snapshot (remap IDs, apply transformations)
455        let (prepared_snapshot, id_map) = prepare_snapshot(snapshot, ids_config, options)?;
456
457        // Step 4: Map entry/exit points to new IDs
458        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        // Step 5: Import the prepared snapshot using merge mode
471        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        // Step 6: Attach entry points to parent task if specified
483        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    /// Instantiate a template from a file path.
498    ///
499    /// Convenience method that loads the snapshot from a file and delegates
500    /// to `instantiate_template`.
501    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    /// Attach template entry points to a parent task via dependency links.
526    ///
527    /// Creates a dependency of the specified type from the parent to each
528    /// entry point task.
529    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    /// Create a minimal test template snapshot.
555    fn make_test_template() -> Snapshot {
556        let mut tables = BTreeMap::new();
557
558        // Two tasks: a root and a child
559        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        // Root contains child
610        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        // Root task should be the only entry point (child is contained)
647        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        // Add an external dependency (child blocks an external task)
658        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        // child-1 should be an exit point because it references an external task
669        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        // All original IDs should be remapped
696        assert!(id_map.contains_key("tpl-root"));
697        assert!(id_map.contains_key("tpl-child-1"));
698
699        // New IDs should be different from originals
700        assert_ne!(id_map["tpl-root"], "tpl-root");
701        assert_ne!(id_map["tpl-child-1"], "tpl-child-1");
702
703        // Prepared snapshot should use new IDs
704        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        // Set tasks to non-pending status
718        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(); // reset_status = true by default
732
733        let (prepared, _) = prepare_snapshot(&snapshot, &ids_config, &options).unwrap();
734
735        // All tasks should be reset to pending
736        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            // Runtime fields should be cleared
740            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        // Should have original tags + extra tags for each task
774        let tags = prepared.tables.get("task_tags").unwrap();
775        // Original: 2 tags + extra: 2 tasks * 2 tags = 4 new tag rows
776        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        // Add some state history
788        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        // State history should be cleared
808        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        // Verify metadata
827        assert_eq!(result.metadata.name, "test-template");
828        assert_eq!(result.metadata.task_count, 2);
829
830        // Verify entry points were mapped
831        assert_eq!(result.entry_point_ids.len(), 1);
832
833        // Verify tasks were imported
834        assert_eq!(result.import_stats.tasks_imported, 2);
835        assert_eq!(result.import_stats.dependencies_imported, 1);
836
837        // Verify ID mapping is complete
838        assert_eq!(result.id_map.len(), 2);
839
840        // Verify tasks exist in the database
841        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        // Create a parent task first
855        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        // Verify parent attachment
874        assert_eq!(result.attached_to_parent, Some("parent-task".to_string()));
875
876        // Verify the dependency was created
877        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        // Instantiate the same template twice
914        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        // IDs should be different between instantiations
922        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        // Both should have all tasks imported
931        assert_eq!(result1.import_stats.tasks_imported, 2);
932        assert_eq!(result2.import_stats.tasks_imported, 2);
933    }
934}