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