Skip to main content

task_graph_mcp/config/
types.rs

1//! Configuration types and structures.
2//!
3//! This module contains all the configuration types used throughout the application.
4
5use crate::format::OutputFormat;
6use anyhow::{Result, anyhow};
7use heck::{ToKebabCase, ToLowerCamelCase, ToSnakeCase, ToTitleCase, ToUpperCamelCase};
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12/// Default port for the web dashboard.
13pub const DEFAULT_UI_PORT: u16 = 31994;
14
15/// Default number of words for generated IDs.
16pub const DEFAULT_ID_WORDS: u8 = 4;
17
18/// Case style for generated IDs.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
20#[serde(rename_all = "kebab-case")]
21pub enum IdCase {
22    /// kebab-case (default): happy-turtle-swift-fox
23    #[default]
24    KebabCase,
25    /// snake_case: happy_turtle_swift_fox
26    SnakeCase,
27    /// camelCase: happyTurtleSwiftFox
28    CamelCase,
29    /// PascalCase: HappyTurtleSwiftFox
30    PascalCase,
31    /// lowercase: happyturtleswiftfox
32    Lowercase,
33    /// UPPERCASE: HAPPYTURTLESWIFTFOX
34    Uppercase,
35    /// Title Case: Happy Turtle Swift Fox
36    TitleCase,
37}
38
39/// ID generation configuration.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct IdsConfig {
42    /// Number of words for generated task IDs (default: 4).
43    #[serde(default = "default_id_words")]
44    pub task_id_words: u8,
45
46    /// Number of words for generated agent IDs (default: 4).
47    #[serde(default = "default_id_words")]
48    pub agent_id_words: u8,
49
50    /// Case style for generated IDs (default: kebab-case).
51    #[serde(default)]
52    pub id_case: IdCase,
53}
54
55fn default_id_words() -> u8 {
56    DEFAULT_ID_WORDS
57}
58
59impl Default for IdsConfig {
60    fn default() -> Self {
61        Self {
62            task_id_words: DEFAULT_ID_WORDS,
63            agent_id_words: DEFAULT_ID_WORDS,
64            id_case: IdCase::default(),
65        }
66    }
67}
68
69impl IdCase {
70    /// Convert a kebab-case string to the target case style.
71    /// Input is expected to be lowercase words separated by hyphens.
72    pub fn convert(&self, input: &str) -> String {
73        match self {
74            IdCase::KebabCase => input.to_kebab_case(),
75            IdCase::SnakeCase => input.to_snake_case(),
76            IdCase::CamelCase => input.to_lower_camel_case(),
77            IdCase::PascalCase => input.to_upper_camel_case(),
78            IdCase::Lowercase => input.replace('-', "").to_lowercase(),
79            IdCase::Uppercase => input.replace('-', "").to_uppercase(),
80            IdCase::TitleCase => input.to_title_case(),
81        }
82    }
83
84    /// Get the separator for this case style (used in petname generation).
85    /// Returns None for cases that don't use separators (camelCase, PascalCase, etc.).
86    pub fn separator(&self) -> Option<&'static str> {
87        match self {
88            IdCase::KebabCase => Some("-"),
89            IdCase::SnakeCase => Some("_"),
90            IdCase::TitleCase => Some(" "),
91            IdCase::CamelCase | IdCase::PascalCase | IdCase::Lowercase | IdCase::Uppercase => None,
92        }
93    }
94}
95
96/// UI mode for the server.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum UiMode {
100    /// No UI, MCP server only (default)
101    #[default]
102    None,
103    /// Enable web dashboard UI
104    Web,
105}
106
107/// UI configuration for the web dashboard.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct UiConfig {
110    /// UI mode: none (MCP only) or web (enable dashboard).
111    #[serde(default)]
112    pub mode: UiMode,
113
114    /// Port for the web dashboard (default: 31994).
115    #[serde(default = "default_ui_port")]
116    pub port: u16,
117
118    /// Initial retry delay in milliseconds when dashboard fails to start (default: 15000).
119    #[serde(default = "default_retry_initial_ms")]
120    pub retry_initial_ms: u64,
121
122    /// Jitter range in milliseconds for retry delay (default: 5000, meaning ±5s).
123    #[serde(default = "default_retry_jitter_ms")]
124    pub retry_jitter_ms: u64,
125
126    /// Maximum retry interval in milliseconds (default: 240000 = 4 minutes).
127    #[serde(default = "default_retry_max_ms")]
128    pub retry_max_ms: u64,
129
130    /// Exponential backoff multiplier (default: 2.0).
131    #[serde(default = "default_retry_multiplier")]
132    pub retry_multiplier: f64,
133}
134
135impl Default for UiConfig {
136    fn default() -> Self {
137        Self {
138            mode: UiMode::default(),
139            port: default_ui_port(),
140            retry_initial_ms: default_retry_initial_ms(),
141            retry_jitter_ms: default_retry_jitter_ms(),
142            retry_max_ms: default_retry_max_ms(),
143            retry_multiplier: default_retry_multiplier(),
144        }
145    }
146}
147
148fn default_ui_port() -> u16 {
149    DEFAULT_UI_PORT
150}
151
152fn default_retry_initial_ms() -> u64 {
153    15_000 // 15 seconds
154}
155
156fn default_retry_jitter_ms() -> u64 {
157    5_000 // ±5 seconds
158}
159
160fn default_retry_max_ms() -> u64 {
161    240_000 // 4 minutes
162}
163
164fn default_retry_multiplier() -> f64 {
165    2.0
166}
167
168/// Auto-advance configuration for automatically transitioning tasks when dependencies are satisfied.
169#[derive(Debug, Clone, Serialize, Deserialize, Default)]
170pub struct AutoAdvanceConfig {
171    /// Enable auto-advance when dependencies are satisfied (default: false).
172    #[serde(default)]
173    pub enabled: bool,
174
175    /// Target state for auto-advanced tasks (e.g., "ready").
176    /// If None, tasks remain in their current state even when unblocked.
177    #[serde(default)]
178    pub target_state: Option<String>,
179}
180
181/// Behavior for unknown attachment keys.
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
183#[serde(rename_all = "snake_case")]
184pub enum UnknownKeyBehavior {
185    /// Silently use default mime/mode.
186    Allow,
187    /// Use defaults but return a warning in the response (default).
188    #[default]
189    Warn,
190    /// Reject unknown keys with an error.
191    Reject,
192}
193
194/// Enforcement level for workflow gates (checklists that must be satisfied before status transitions).
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
196#[serde(rename_all = "snake_case")]
197pub enum GateEnforcement {
198    /// Advisory only, never blocks transitions. Unsatisfied gates are reported but do not prevent status changes.
199    Allow,
200    /// Blocks transition unless force=true (default). Requires explicit override to proceed with unsatisfied gates.
201    #[default]
202    Warn,
203    /// Hard block, cannot be forced. Transition is rejected until gate requirements are satisfied.
204    Reject,
205}
206
207/// Definition of a gate (checklist item) for status or phase exits.
208///
209/// Gates are checked when transitioning out of a status or phase. A gate is satisfied
210/// when the task has an attachment with a matching type (e.g., "gate/tests", "gate/commit").
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct GateDefinition {
213    /// Attachment type that satisfies this gate (e.g., "gate/tests", "gate/commit").
214    #[serde(rename = "type")]
215    pub gate_type: String,
216
217    /// Enforcement level for this gate.
218    #[serde(default)]
219    pub enforcement: GateEnforcement,
220
221    /// Human-readable description of what this gate requires.
222    #[serde(default)]
223    pub description: String,
224}
225
226/// Definition of a preconfigured attachment key.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct AttachmentKeyDefinition {
229    /// Default MIME type for this key.
230    pub mime: String,
231    /// Default mode: "append" or "replace".
232    #[serde(default = "default_append_mode")]
233    pub mode: String,
234}
235
236fn default_append_mode() -> String {
237    "append".to_string()
238}
239
240/// Attachments configuration with preconfigured key definitions.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct AttachmentsConfig {
243    /// Behavior for unknown attachment keys (allow, warn, reject).
244    #[serde(default)]
245    pub unknown_key: UnknownKeyBehavior,
246    /// Preconfigured attachment key definitions.
247    #[serde(default = "AttachmentsConfig::default_definitions")]
248    pub definitions: HashMap<String, AttachmentKeyDefinition>,
249}
250
251impl Default for AttachmentsConfig {
252    fn default() -> Self {
253        Self {
254            unknown_key: UnknownKeyBehavior::default(),
255            definitions: Self::default_definitions(),
256        }
257    }
258}
259
260impl AttachmentsConfig {
261    /// Default attachment key definitions.
262    pub fn default_definitions() -> HashMap<String, AttachmentKeyDefinition> {
263        let mut defs = HashMap::new();
264
265        defs.insert(
266            "commit".to_string(),
267            AttachmentKeyDefinition {
268                mime: "text/git.hash".to_string(),
269                mode: "append".to_string(),
270            },
271        );
272
273        defs.insert(
274            "checkin".to_string(),
275            AttachmentKeyDefinition {
276                mime: "text/p4.changelist".to_string(),
277                mode: "append".to_string(),
278            },
279        );
280
281        defs.insert(
282            "meta".to_string(),
283            AttachmentKeyDefinition {
284                mime: "application/json".to_string(),
285                mode: "replace".to_string(),
286            },
287        );
288
289        defs.insert(
290            "note".to_string(),
291            AttachmentKeyDefinition {
292                mime: "text/plain".to_string(),
293                mode: "append".to_string(),
294            },
295        );
296
297        defs.insert(
298            "log".to_string(),
299            AttachmentKeyDefinition {
300                mime: "text/plain".to_string(),
301                mode: "append".to_string(),
302            },
303        );
304
305        defs.insert(
306            "error".to_string(),
307            AttachmentKeyDefinition {
308                mime: "text/plain".to_string(),
309                mode: "append".to_string(),
310            },
311        );
312
313        defs.insert(
314            "output".to_string(),
315            AttachmentKeyDefinition {
316                mime: "text/plain".to_string(),
317                mode: "append".to_string(),
318            },
319        );
320
321        defs.insert(
322            "diff".to_string(),
323            AttachmentKeyDefinition {
324                mime: "text/x-diff".to_string(),
325                mode: "append".to_string(),
326            },
327        );
328
329        defs.insert(
330            "changelist".to_string(),
331            AttachmentKeyDefinition {
332                mime: "text/plain".to_string(),
333                mode: "append".to_string(),
334            },
335        );
336
337        defs.insert(
338            "plan".to_string(),
339            AttachmentKeyDefinition {
340                mime: "text/markdown".to_string(),
341                mode: "replace".to_string(),
342            },
343        );
344
345        defs.insert(
346            "result".to_string(),
347            AttachmentKeyDefinition {
348                mime: "application/json".to_string(),
349                mode: "replace".to_string(),
350            },
351        );
352
353        defs.insert(
354            "context".to_string(),
355            AttachmentKeyDefinition {
356                mime: "text/plain".to_string(),
357                mode: "replace".to_string(),
358            },
359        );
360
361        // Gate attachments - for workflow gate satisfaction
362        defs.insert(
363            "gate/tests".to_string(),
364            AttachmentKeyDefinition {
365                mime: "text/plain".to_string(),
366                mode: "append".to_string(),
367            },
368        );
369
370        defs.insert(
371            "gate/commit".to_string(),
372            AttachmentKeyDefinition {
373                mime: "text/plain".to_string(),
374                mode: "append".to_string(),
375            },
376        );
377
378        defs.insert(
379            "gate/review".to_string(),
380            AttachmentKeyDefinition {
381                mime: "text/plain".to_string(),
382                mode: "append".to_string(),
383            },
384        );
385
386        defs
387    }
388
389    /// Get the definition for a key, if it exists.
390    pub fn get_definition(&self, key: &str) -> Option<&AttachmentKeyDefinition> {
391        self.definitions.get(key)
392    }
393
394    /// Check if a key is a known/configured key.
395    pub fn is_known_key(&self, key: &str) -> bool {
396        self.definitions.contains_key(key)
397    }
398
399    /// Get the default MIME type for a key, or fallback to text/plain.
400    pub fn get_mime_default(&self, key: &str) -> &str {
401        self.definitions
402            .get(key)
403            .map(|d| d.mime.as_str())
404            .unwrap_or("text/plain")
405    }
406
407    /// Get the default mode for a key, or fallback to "append".
408    pub fn get_mode_default(&self, key: &str) -> &str {
409        self.definitions
410            .get(key)
411            .map(|d| d.mode.as_str())
412            .unwrap_or("append")
413    }
414}
415
416/// Definition of a preconfigured tag.
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct TagDefinition {
419    /// Category for grouping (e.g., "language", "domain", "type").
420    #[serde(default)]
421    pub category: Option<String>,
422    /// Human-readable description.
423    #[serde(default)]
424    pub description: Option<String>,
425}
426
427/// Tags configuration with preconfigured tag definitions.
428#[derive(Debug, Clone, Serialize, Deserialize, Default)]
429pub struct TagsConfig {
430    /// Behavior for unknown tags (allow, warn, reject).
431    #[serde(default)]
432    pub unknown_tag: UnknownKeyBehavior,
433    /// Preconfigured tag definitions.
434    #[serde(default)]
435    pub definitions: HashMap<String, TagDefinition>,
436}
437
438impl TagsConfig {
439    /// Check if a tag is a known/defined tag.
440    pub fn is_known_tag(&self, tag: &str) -> bool {
441        self.definitions.contains_key(tag)
442    }
443
444    /// Get all defined tag names.
445    pub fn tag_names(&self) -> Vec<&str> {
446        self.definitions.keys().map(|s| s.as_str()).collect()
447    }
448
449    /// Get all tags in a specific category.
450    pub fn tags_in_category(&self, category: &str) -> Vec<&str> {
451        self.definitions
452            .iter()
453            .filter(|(_, def)| def.category.as_deref() == Some(category))
454            .map(|(name, _)| name.as_str())
455            .collect()
456    }
457
458    /// Get all unique categories.
459    pub fn categories(&self) -> Vec<&str> {
460        let mut cats: Vec<&str> = self
461            .definitions
462            .values()
463            .filter_map(|def| def.category.as_deref())
464            .collect();
465        cats.sort();
466        cats.dedup();
467        cats
468    }
469
470    /// Validate a single tag, returning Ok(None) if valid, Ok(Some(warning)) for warn mode, or Err for reject.
471    pub fn validate_tag(&self, tag: &str) -> Result<Option<String>> {
472        if self.is_known_tag(tag) {
473            return Ok(None);
474        }
475
476        match self.unknown_tag {
477            UnknownKeyBehavior::Allow => Ok(None),
478            UnknownKeyBehavior::Warn => Ok(Some(format!(
479                "Unknown tag '{}'. Known tags: {:?}",
480                tag,
481                self.tag_names()
482            ))),
483            UnknownKeyBehavior::Reject => Err(anyhow!(
484                "Unknown tag '{}'. Configure in tags.definitions or set unknown_tag to 'allow' or 'warn'. Known tags: {:?}",
485                tag,
486                self.tag_names()
487            )),
488        }
489    }
490
491    /// Validate multiple tags, collecting warnings and stopping on first reject.
492    pub fn validate_tags(&self, tags: &[String]) -> Result<Vec<String>> {
493        let mut warnings = Vec::new();
494        for tag in tags {
495            if let Some(warning) = self.validate_tag(tag)? {
496                warnings.push(warning);
497            }
498        }
499        Ok(warnings)
500    }
501}
502
503/// Server configuration.
504#[derive(Debug, Clone, Serialize, Deserialize, Default)]
505pub struct Config {
506    #[serde(default)]
507    pub server: ServerConfig,
508
509    #[serde(default)]
510    pub paths: PathsConfig,
511
512    #[serde(default)]
513    pub states: StatesConfig,
514
515    #[serde(default)]
516    pub dependencies: DependenciesConfig,
517
518    #[serde(default)]
519    pub auto_advance: AutoAdvanceConfig,
520
521    #[serde(default)]
522    pub attachments: AttachmentsConfig,
523
524    #[serde(default)]
525    pub phases: PhasesConfig,
526
527    #[serde(default)]
528    pub tags: TagsConfig,
529
530    #[serde(default)]
531    pub ids: IdsConfig,
532}
533
534/// Paths configured for the server, returned by connect.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct ServerPaths {
537    /// Path to the SQLite database file.
538    pub db_path: PathBuf,
539    /// Path to the media directory for file attachments.
540    pub media_dir: PathBuf,
541    /// Path to the log directory.
542    pub log_dir: PathBuf,
543    /// Path to the configuration file (if one was loaded).
544    pub config_path: Option<PathBuf>,
545}
546
547/// Server-specific configuration.
548#[derive(Debug, Clone, Serialize, Deserialize)]
549pub struct ServerConfig {
550    /// Path to the SQLite database file.
551    #[serde(default = "default_db_path")]
552    pub db_path: PathBuf,
553
554    /// Path to the media directory for file attachments.
555    #[serde(default = "default_media_dir")]
556    pub media_dir: PathBuf,
557
558    /// Maximum claims per agent.
559    #[serde(default = "default_claim_limit")]
560    pub claim_limit: i32,
561
562    /// Timeout for stale claims in seconds.
563    #[serde(default = "default_stale_timeout")]
564    pub stale_timeout_seconds: i64,
565
566    /// Default output format for query results (json or markdown).
567    #[serde(default)]
568    pub default_format: OutputFormat,
569
570    /// Path to the skills directory for skill overrides.
571    #[serde(default = "default_skills_dir")]
572    pub skills_dir: PathBuf,
573
574    /// Path to the log directory.
575    #[serde(default = "default_log_dir")]
576    pub log_dir: PathBuf,
577
578    /// UI configuration for the web dashboard.
579    #[serde(default)]
580    pub ui: UiConfig,
581
582    /// Default workflow name (e.g., "swarm" to use workflow-swarm.yaml).
583    /// If set, this workflow is used as the default for workers that don't specify one.
584    /// The named workflow is also cached under both its name and "default".
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub default_workflow: Option<String>,
587
588    /// Default page size for paginated queries (list_tasks, search).
589    /// Applies when no explicit limit is provided. Default: 50. Max: 1000.
590    #[serde(default = "default_page_size")]
591    pub default_page_size: i32,
592}
593
594impl Default for ServerConfig {
595    fn default() -> Self {
596        Self {
597            db_path: default_db_path(),
598            media_dir: default_media_dir(),
599            claim_limit: default_claim_limit(),
600            stale_timeout_seconds: default_stale_timeout(),
601            default_format: OutputFormat::default(),
602            skills_dir: default_skills_dir(),
603            log_dir: default_log_dir(),
604            ui: UiConfig::default(),
605            default_workflow: None,
606            default_page_size: default_page_size(),
607        }
608    }
609}
610
611fn default_db_path() -> PathBuf {
612    PathBuf::from("task-graph/tasks.db")
613}
614
615fn default_media_dir() -> PathBuf {
616    PathBuf::from("task-graph/media")
617}
618
619fn default_skills_dir() -> PathBuf {
620    PathBuf::from("task-graph/skills")
621}
622
623fn default_log_dir() -> PathBuf {
624    PathBuf::from("task-graph/logs")
625}
626
627fn default_paths_root() -> String {
628    ".".to_string()
629}
630
631fn default_claim_limit() -> i32 {
632    5
633}
634
635fn default_stale_timeout() -> i64 {
636    900 // 15 minutes
637}
638
639fn default_page_size() -> i32 {
640    50
641}
642
643/// Path handling configuration.
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct PathsConfig {
646    /// Root directory for sandboxing (default: ".")
647    #[serde(default = "default_paths_root")]
648    pub root: String,
649
650    /// Style for representing file paths.
651    #[serde(default)]
652    pub style: PathStyle,
653
654    /// Auto-map single-letter Windows drives (default: false)
655    #[serde(default)]
656    pub map_windows_drives: bool,
657
658    /// Prefix mappings (prefix -> path)
659    /// Values can be: literal path, $ENV_VAR, or ${config.path}
660    #[serde(default)]
661    pub mappings: HashMap<String, String>,
662}
663
664impl Default for PathsConfig {
665    fn default() -> Self {
666        Self {
667            root: default_paths_root(),
668            style: PathStyle::Relative,
669            map_windows_drives: false,
670            mappings: HashMap::new(),
671        }
672    }
673}
674
675/// Path style for file locks.
676#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
677#[serde(rename_all = "snake_case")]
678#[derive(Default)]
679pub enum PathStyle {
680    /// Relative paths (e.g., src/main.rs)
681    #[default]
682    Relative,
683    /// Project-prefixed paths (e.g., ${project}/src/main.rs)
684    ProjectPrefixed,
685}
686
687/// Task state configuration.
688#[derive(Debug, Clone, Serialize, Deserialize)]
689pub struct StatesConfig {
690    /// Default state for new tasks.
691    #[serde(default = "default_initial_state")]
692    pub initial: String,
693
694    /// Default state for tasks when their owner disconnects (must be untimed).
695    #[serde(default = "default_disconnect_state")]
696    pub disconnect_state: String,
697
698    /// States that block dependent tasks (tasks in these states count as "not done").
699    #[serde(default = "default_blocking_states")]
700    pub blocking_states: Vec<String>,
701
702    /// State definitions with allowed transitions and timing behavior.
703    #[serde(default = "default_state_definitions")]
704    pub definitions: HashMap<String, StateDefinition>,
705}
706
707impl Default for StatesConfig {
708    fn default() -> Self {
709        Self {
710            initial: default_initial_state(),
711            disconnect_state: default_disconnect_state(),
712            blocking_states: default_blocking_states(),
713            definitions: default_state_definitions(),
714        }
715    }
716}
717
718fn default_initial_state() -> String {
719    "pending".to_string()
720}
721
722fn default_disconnect_state() -> String {
723    "pending".to_string()
724}
725
726fn default_blocking_states() -> Vec<String> {
727    vec![
728        "pending".to_string(),
729        "assigned".to_string(),
730        "working".to_string(),
731        "consult".to_string(),
732    ]
733}
734
735fn default_state_definitions() -> HashMap<String, StateDefinition> {
736    let mut defs = HashMap::new();
737
738    defs.insert(
739        "pending".to_string(),
740        StateDefinition {
741            exits: vec![
742                "assigned".to_string(),
743                "working".to_string(),
744                "cancelled".to_string(),
745            ],
746            timed: false,
747        },
748    );
749
750    defs.insert(
751        "assigned".to_string(),
752        StateDefinition {
753            exits: vec![
754                "working".to_string(),
755                "pending".to_string(),
756                "cancelled".to_string(),
757            ],
758            timed: false,
759        },
760    );
761
762    defs.insert(
763        "working".to_string(),
764        StateDefinition {
765            exits: vec![
766                "completed".to_string(),
767                "failed".to_string(),
768                "pending".to_string(),
769                "consult".to_string(),
770            ],
771            timed: true,
772        },
773    );
774
775    defs.insert(
776        "completed".to_string(),
777        StateDefinition {
778            exits: vec!["pending".to_string()],
779            timed: false,
780        },
781    );
782
783    defs.insert(
784        "failed".to_string(),
785        StateDefinition {
786            exits: vec!["pending".to_string()],
787            timed: false,
788        },
789    );
790
791    defs.insert(
792        "consult".to_string(),
793        StateDefinition {
794            exits: vec![
795                "working".to_string(),
796                "pending".to_string(),
797                "cancelled".to_string(),
798            ],
799            timed: false,
800        },
801    );
802
803    defs.insert(
804        "cancelled".to_string(),
805        StateDefinition {
806            exits: vec![],
807            timed: false,
808        },
809    );
810
811    defs
812}
813
814/// Definition of a single task state.
815#[derive(Debug, Clone, Serialize, Deserialize)]
816pub struct StateDefinition {
817    /// Allowed states to transition to from this state.
818    #[serde(default)]
819    pub exits: Vec<String>,
820
821    /// Whether time spent in this state should be tracked (accumulated to time_actual_ms).
822    #[serde(default)]
823    pub timed: bool,
824}
825
826/// Dependency type configuration.
827#[derive(Debug, Clone, Serialize, Deserialize)]
828pub struct DependenciesConfig {
829    /// Dependency type definitions.
830    #[serde(default = "default_dependency_definitions")]
831    pub definitions: HashMap<String, DependencyDefinition>,
832}
833
834impl Default for DependenciesConfig {
835    fn default() -> Self {
836        Self {
837            definitions: default_dependency_definitions(),
838        }
839    }
840}
841
842/// Definition of a dependency type.
843#[derive(Debug, Clone, Serialize, Deserialize)]
844pub struct DependencyDefinition {
845    /// Display orientation: "horizontal" (same level) or "vertical" (parent-child).
846    pub display: DependencyDisplay,
847
848    /// What this dependency blocks: "start" (blocks claiming) or "completion" (blocks completing).
849    pub blocks: BlockTarget,
850}
851
852/// Display orientation for dependency visualization.
853#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
854#[serde(rename_all = "snake_case")]
855pub enum DependencyDisplay {
856    /// Same level dependencies (blocks, follows).
857    Horizontal,
858    /// Parent-child relationships (contains).
859    Vertical,
860}
861
862/// What a dependency blocks.
863#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
864#[serde(rename_all = "snake_case")]
865pub enum BlockTarget {
866    /// Does not block - informational link only.
867    None,
868    /// Blocks the task from being started/claimed.
869    Start,
870    /// Blocks the task from being completed.
871    Completion,
872}
873
874fn default_dependency_definitions() -> HashMap<String, DependencyDefinition> {
875    let mut defs = HashMap::new();
876
877    // Primary workflow types (blocking)
878    defs.insert(
879        "blocks".to_string(),
880        DependencyDefinition {
881            display: DependencyDisplay::Horizontal,
882            blocks: BlockTarget::Start,
883        },
884    );
885
886    defs.insert(
887        "follows".to_string(),
888        DependencyDefinition {
889            display: DependencyDisplay::Horizontal,
890            blocks: BlockTarget::Start,
891        },
892    );
893
894    defs.insert(
895        "contains".to_string(),
896        DependencyDefinition {
897            display: DependencyDisplay::Vertical,
898            blocks: BlockTarget::Completion,
899        },
900    );
901
902    // Non-blocking relationship types
903    defs.insert(
904        "duplicate".to_string(),
905        DependencyDefinition {
906            display: DependencyDisplay::Horizontal,
907            blocks: BlockTarget::None,
908        },
909    );
910
911    defs.insert(
912        "see-also".to_string(),
913        DependencyDefinition {
914            display: DependencyDisplay::Horizontal,
915            blocks: BlockTarget::None,
916        },
917    );
918
919    defs.insert(
920        "relates-to".to_string(),
921        DependencyDefinition {
922            display: DependencyDisplay::Horizontal,
923            blocks: BlockTarget::None,
924        },
925    );
926
927    defs
928}
929
930impl DependenciesConfig {
931    /// Check if a dependency type is valid.
932    pub fn is_valid_dep_type(&self, dep_type: &str) -> bool {
933        self.definitions.contains_key(dep_type)
934    }
935
936    /// Get the definition for a dependency type.
937    pub fn get_definition(&self, dep_type: &str) -> Option<&DependencyDefinition> {
938        self.definitions.get(dep_type)
939    }
940
941    /// Get all dependency types that block start.
942    pub fn start_blocking_types(&self) -> Vec<&str> {
943        self.definitions
944            .iter()
945            .filter(|(_, def)| def.blocks == BlockTarget::Start)
946            .map(|(name, _)| name.as_str())
947            .collect()
948    }
949
950    /// Get all dependency types that block completion.
951    pub fn completion_blocking_types(&self) -> Vec<&str> {
952        self.definitions
953            .iter()
954            .filter(|(_, def)| def.blocks == BlockTarget::Completion)
955            .map(|(name, _)| name.as_str())
956            .collect()
957    }
958
959    /// Get all vertical (parent-child) dependency types.
960    pub fn vertical_types(&self) -> Vec<&str> {
961        self.definitions
962            .iter()
963            .filter(|(_, def)| def.display == DependencyDisplay::Vertical)
964            .map(|(name, _)| name.as_str())
965            .collect()
966    }
967
968    /// Get all dependency type names.
969    pub fn dep_type_names(&self) -> Vec<&str> {
970        self.definitions.keys().map(|s| s.as_str()).collect()
971    }
972
973    /// Validate the dependencies configuration.
974    pub fn validate(&self) -> anyhow::Result<()> {
975        if self.definitions.is_empty() {
976            return Err(anyhow::anyhow!(
977                "At least one dependency type must be defined"
978            ));
979        }
980
981        // Check for at least one start-blocking type (for task sequencing)
982        let has_start_blocking = self
983            .definitions
984            .values()
985            .any(|d| d.blocks == BlockTarget::Start);
986        if !has_start_blocking {
987            return Err(anyhow::anyhow!(
988                "At least one dependency type with blocks: start must be defined"
989            ));
990        }
991
992        Ok(())
993    }
994}
995
996impl StatesConfig {
997    /// Check if a state is a valid defined state.
998    pub fn is_valid_state(&self, state: &str) -> bool {
999        self.definitions.contains_key(state)
1000    }
1001
1002    /// Check if a transition from one state to another is allowed.
1003    pub fn is_valid_transition(&self, from: &str, to: &str) -> bool {
1004        if let Some(def) = self.definitions.get(from) {
1005            def.exits.contains(&to.to_string())
1006        } else {
1007            false
1008        }
1009    }
1010
1011    /// Check if a state is timed (accumulates duration).
1012    pub fn is_timed_state(&self, state: &str) -> bool {
1013        self.definitions
1014            .get(state)
1015            .map(|d| d.timed)
1016            .unwrap_or(false)
1017    }
1018
1019    /// Check if a state is terminal (has no exits).
1020    pub fn is_terminal_state(&self, state: &str) -> bool {
1021        self.definitions
1022            .get(state)
1023            .map(|d| d.exits.is_empty())
1024            .unwrap_or(false)
1025    }
1026
1027    /// Check if a state is a blocking state (blocks dependents).
1028    pub fn is_blocking_state(&self, state: &str) -> bool {
1029        self.blocking_states.contains(&state.to_string())
1030    }
1031
1032    /// Get all defined state names.
1033    pub fn state_names(&self) -> Vec<&str> {
1034        self.definitions.keys().map(|s| s.as_str()).collect()
1035    }
1036
1037    /// Get allowed exit states for a given state.
1038    pub fn get_exits(&self, state: &str) -> Vec<&str> {
1039        self.definitions
1040            .get(state)
1041            .map(|d| d.exits.iter().map(|s| s.as_str()).collect())
1042            .unwrap_or_default()
1043    }
1044
1045    /// Get all untimed state names (valid for disconnect final_state).
1046    pub fn untimed_state_names(&self) -> Vec<&str> {
1047        self.definitions
1048            .iter()
1049            .filter(|(_, def)| !def.timed)
1050            .map(|(name, _)| name.as_str())
1051            .collect()
1052    }
1053
1054    /// Validate the states configuration.
1055    pub fn validate(&self) -> Result<()> {
1056        // Check initial state exists
1057        if !self.definitions.contains_key(&self.initial) {
1058            return Err(anyhow!(
1059                "Initial state '{}' is not defined in state definitions",
1060                self.initial
1061            ));
1062        }
1063
1064        // Check disconnect_state exists and is not timed
1065        if !self.definitions.contains_key(&self.disconnect_state) {
1066            return Err(anyhow!(
1067                "Disconnect state '{}' is not defined in state definitions",
1068                self.disconnect_state
1069            ));
1070        }
1071        if self.is_timed_state(&self.disconnect_state) {
1072            return Err(anyhow!(
1073                "Disconnect state '{}' must not be a timed state",
1074                self.disconnect_state
1075            ));
1076        }
1077
1078        // Check all blocking_states exist
1079        for state in &self.blocking_states {
1080            if !self.definitions.contains_key(state) {
1081                return Err(anyhow!(
1082                    "Blocking state '{}' is not defined in state definitions",
1083                    state
1084                ));
1085            }
1086        }
1087
1088        // Check all exit targets exist
1089        for (state_name, def) in &self.definitions {
1090            for exit in &def.exits {
1091                if !self.definitions.contains_key(exit) {
1092                    return Err(anyhow!(
1093                        "State '{}' has exit '{}' which is not defined",
1094                        state_name,
1095                        exit
1096                    ));
1097                }
1098            }
1099        }
1100
1101        // Check at least one terminal state exists
1102        let has_terminal = self.definitions.values().any(|d| d.exits.is_empty());
1103        if !has_terminal {
1104            return Err(anyhow!(
1105                "At least one terminal state (with empty exits) must be defined"
1106            ));
1107        }
1108
1109        Ok(())
1110    }
1111}
1112
1113/// Phase configuration for categorizing type of work.
1114#[derive(Debug, Clone, Serialize, Deserialize)]
1115pub struct PhasesConfig {
1116    /// Behavior for unknown phase values (allow, warn, reject).
1117    #[serde(default)]
1118    pub unknown_phase: UnknownKeyBehavior,
1119
1120    /// Known phase definitions.
1121    #[serde(default = "default_phases")]
1122    pub definitions: HashSet<String>,
1123}
1124
1125impl Default for PhasesConfig {
1126    fn default() -> Self {
1127        Self {
1128            unknown_phase: UnknownKeyBehavior::Warn,
1129            definitions: default_phases(),
1130        }
1131    }
1132}
1133
1134fn default_phases() -> HashSet<String> {
1135    [
1136        "deliver",   // Top-level deliverable
1137        "triage",    // Initial assessment and prioritization
1138        "explore",   // Research and discovery
1139        "diagnose",  // Debugging and troubleshooting
1140        "design",    // Architecture and design
1141        "plan",      // Planning and specification
1142        "implement", // Implementation/coding
1143        "test",      // Testing and validation
1144        "review",    // Code review
1145        "security",  // Security review/audit
1146        "doc",       // Documentation
1147        "integrate", // Integration work
1148        "deploy",    // Release to staging/production
1149        "monitor",   // Observability and metrics
1150        "optimize",  // Performance tuning
1151    ]
1152    .iter()
1153    .map(|s| s.to_string())
1154    .collect()
1155}
1156
1157impl PhasesConfig {
1158    /// Check if a phase is a known/defined phase.
1159    pub fn is_known_phase(&self, phase: &str) -> bool {
1160        self.definitions.contains(phase)
1161    }
1162
1163    /// Get all defined phase names.
1164    pub fn phase_names(&self) -> Vec<&str> {
1165        self.definitions.iter().map(|s| s.as_str()).collect()
1166    }
1167
1168    /// Check a phase and return a warning message if unknown (based on unknown_phase behavior).
1169    /// Returns None if the phase is known or if behavior is Allow.
1170    /// Returns Some(warning) if behavior is Warn.
1171    /// Returns Err if behavior is Reject.
1172    pub fn check_phase(&self, phase: &str) -> Result<Option<String>> {
1173        if self.is_known_phase(phase) {
1174            return Ok(None);
1175        }
1176
1177        match self.unknown_phase {
1178            UnknownKeyBehavior::Allow => Ok(None),
1179            UnknownKeyBehavior::Warn => Ok(Some(format!(
1180                "Unknown phase '{}'. Known phases: {:?}",
1181                phase,
1182                self.phase_names()
1183            ))),
1184            UnknownKeyBehavior::Reject => Err(anyhow!(
1185                "Unknown phase '{}'. Known phases: {:?}. Configure in phases.definitions or set unknown_phase to 'allow' or 'warn'.",
1186                phase,
1187                self.phase_names()
1188            )),
1189        }
1190    }
1191}
1192
1193impl Config {
1194    /// Load configuration from file.
1195    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1196        let content = std::fs::read_to_string(path)?;
1197        let config: Config = serde_yaml::from_str(&content)?;
1198        Ok(config)
1199    }
1200
1201    /// Load configuration from default locations or return defaults.
1202    ///
1203    /// **Deprecated**: Use `ConfigLoader::load()` instead for proper tier merging.
1204    pub fn load_or_default() -> Self {
1205        // Try TASK_GRAPH_CONFIG_PATH environment variable first
1206        if let Ok(config_path) = std::env::var("TASK_GRAPH_CONFIG_PATH")
1207            && let Ok(config) = Self::load(&config_path)
1208        {
1209            return config;
1210        }
1211
1212        // Try task-graph/config.yaml (new location)
1213        if let Ok(config) = Self::load("task-graph/config.yaml") {
1214            return config;
1215        }
1216
1217        // Try .task-graph/config.yaml (deprecated location)
1218        if let Ok(config) = Self::load(".task-graph/config.yaml") {
1219            return config;
1220        }
1221
1222        // Fall back to defaults with environment variable overrides
1223        let mut config = Self::default();
1224
1225        if let Ok(db_path) = std::env::var("TASK_GRAPH_DB_PATH") {
1226            config.server.db_path = PathBuf::from(db_path);
1227        }
1228
1229        if let Ok(media_dir) = std::env::var("TASK_GRAPH_MEDIA_DIR") {
1230            config.server.media_dir = PathBuf::from(media_dir);
1231        }
1232
1233        if let Ok(log_dir) = std::env::var("TASK_GRAPH_LOG_DIR") {
1234            config.server.log_dir = PathBuf::from(log_dir);
1235        }
1236
1237        config
1238    }
1239
1240    /// Ensure the database directory exists.
1241    pub fn ensure_db_dir(&self) -> Result<()> {
1242        if let Some(parent) = self.server.db_path.parent() {
1243            std::fs::create_dir_all(parent)?;
1244        }
1245        Ok(())
1246    }
1247
1248    /// Ensure the media directory exists.
1249    pub fn ensure_media_dir(&self) -> Result<()> {
1250        std::fs::create_dir_all(&self.server.media_dir)?;
1251        Ok(())
1252    }
1253
1254    /// Ensure the log directory exists.
1255    pub fn ensure_log_dir(&self) -> Result<()> {
1256        std::fs::create_dir_all(&self.server.log_dir)?;
1257        Ok(())
1258    }
1259
1260    /// Get the media directory path.
1261    pub fn media_dir(&self) -> &Path {
1262        &self.server.media_dir
1263    }
1264
1265    /// Get the log directory path.
1266    pub fn log_dir(&self) -> &Path {
1267        &self.server.log_dir
1268    }
1269}
1270
1271/// Tool description override.
1272#[derive(Debug, Clone, Serialize, Deserialize)]
1273pub struct ToolPrompt {
1274    pub description: String,
1275}
1276
1277/// LLM-facing prompts configuration.
1278#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1279pub struct Prompts {
1280    /// Server instructions shown to the LLM.
1281    pub instructions: Option<String>,
1282
1283    /// Tool description overrides by tool name.
1284    #[serde(default)]
1285    pub tools: HashMap<String, ToolPrompt>,
1286}
1287
1288impl Prompts {
1289    /// Load prompts from file.
1290    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
1291        let content = std::fs::read_to_string(path)?;
1292        // Handle empty or comment-only YAML files (which parse as null)
1293        let prompts: Option<Prompts> = serde_yaml::from_str(&content)?;
1294        Ok(prompts.unwrap_or_default())
1295    }
1296
1297    /// Load prompts from default location or return defaults.
1298    ///
1299    /// **Deprecated**: Use `ConfigLoader` for proper tier merging.
1300    pub fn load_or_default() -> Self {
1301        // Try task-graph/prompts.yaml (new location)
1302        if let Ok(prompts) = Self::load("task-graph/prompts.yaml") {
1303            return prompts;
1304        }
1305
1306        // Try .task-graph/prompts.yaml (deprecated location)
1307        if let Ok(prompts) = Self::load(".task-graph/prompts.yaml") {
1308            return prompts;
1309        }
1310
1311        Self::default()
1312    }
1313
1314    /// Get a tool description override if available.
1315    pub fn get_tool_description(&self, name: &str) -> Option<&str> {
1316        self.tools.get(name).map(|t| t.description.as_str())
1317    }
1318}