Skip to main content

post_cortex_daemon/daemon/
validate.rs

1// Copyright (c) 2025 Julius ML
2// MIT License
3
4//! Custom validation layer for MCP tool parameters.
5//!
6//! This module provides business logic validation for tool parameters,
7//! complementing the type coercion layer in `coerce.rs`.
8//!
9//! Validations include:
10//! - UUID format validation
11//! - Enum value validation (interaction_type, scope, etc.)
12//! - Numeric limit validation
13//! - Business rule validation
14
15use crate::daemon::coerce::CoercionError;
16use uuid::Uuid;
17
18/// Validate a session ID is a valid UUID and return the parsed value.
19///
20/// # Arguments
21///
22/// * `session_id` - The session ID string to validate
23///
24/// # Returns
25///
26/// * `Ok(Uuid)` - The parsed UUID if valid
27/// * `Err(CoercionError)` with helpful message if invalid
28///
29/// # Example
30///
31/// ```rust,ignore
32/// use post_cortex::daemon::validate::validate_session_id;
33/// use uuid::Uuid;
34///
35/// // Valid UUID
36/// assert!(validate_session_id("60c598e2-d602-4e07-a328-c458006d48c7").is_ok());
37///
38/// // Invalid UUID
39/// assert!(validate_session_id("invalid").is_err());
40/// ```
41pub fn validate_session_id(session_id: &str) -> Result<Uuid, CoercionError> {
42    Uuid::parse_str(session_id).map_err(|_| CoercionError::new(
43        &format!("Invalid UUID format: '{}'", session_id),
44        std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid UUID"),
45        Some(session_id.into()),
46    )
47    .with_parameter_path("session_id".to_string())
48    .with_expected_type("UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)")
49    .with_hint("Create a session first using the 'session' tool with action='create', or search for existing sessions using 'semantic_search'"))
50}
51
52/// Validate a workspace ID is a valid UUID and return the parsed value.
53///
54/// # Arguments
55///
56/// * `workspace_id` - The workspace ID string to validate
57///
58/// # Returns
59///
60/// * `Ok(Uuid)` - The parsed UUID if valid
61/// * `Err(CoercionError)` with helpful message if invalid
62pub fn validate_workspace_id(workspace_id: &str) -> Result<Uuid, CoercionError> {
63    Uuid::parse_str(workspace_id).map_err(|_| CoercionError::new(
64        &format!("Invalid workspace ID format: '{}'", workspace_id),
65        std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid UUID"),
66        Some(workspace_id.into()),
67    )
68    .with_parameter_path("workspace_id".to_string())
69    .with_expected_type("UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (36 chars with hyphens)")
70    .with_hint("Use the 'manage_workspace' tool with action='list' to see available workspaces, or create one with action='create'"))
71}
72
73/// Valid interaction type values
74pub const VALID_INTERACTION_TYPES: &[&str] = &[
75    "qa",
76    "decision_made",
77    "problem_solved",
78    "code_change",
79    "requirement_added",
80    "concept_defined",
81];
82
83/// Validate an interaction type is one of the valid values.
84///
85/// # Arguments
86///
87/// * `interaction_type` - The interaction type string to validate
88///
89/// # Valid Values
90///
91/// - `qa`: Questions and answers about codebase
92/// - `decision_made`: Architectural decisions and trade-offs
93/// - `problem_solved`: Bug fixes and technical solutions
94/// - `code_change`: Code modifications and refactoring
95/// - `requirement_added`: New requirements or constraints
96/// - `concept_defined`: Technical concepts and patterns explained
97///
98/// # Returns
99///
100/// * `Ok(())` if the interaction type is valid
101/// * `Err(CoercionError)` with helpful message if invalid
102pub fn validate_interaction_type(interaction_type: &str) -> Result<(), CoercionError> {
103    if VALID_INTERACTION_TYPES.contains(&interaction_type.to_lowercase().as_str()) {
104        Ok(())
105    } else {
106        Err(CoercionError::new(
107            &format!("Invalid interaction_type: '{}'", interaction_type),
108            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid type"),
109            Some(interaction_type.into()),
110        )
111        .with_parameter_path("interaction_type".to_string())
112        .with_expected_type(&format!("one of: {}", VALID_INTERACTION_TYPES.join(", ")))
113        .with_hint("Use exact lowercase term with underscores. Valid types: qa, decision_made, problem_solved, code_change, requirement_added, concept_defined"))
114    }
115}
116
117/// Validate a scope value for semantic search.
118///
119/// # Arguments
120///
121/// * `scope` - The scope string to validate
122///
123/// # Valid Values
124///
125/// - `session`: Search within a specific session (requires scope_id)
126/// - `workspace`: Search within a workspace (requires scope_id)
127/// - `global`: Search across all data (default)
128///
129/// # Returns
130///
131/// * `Ok(())` if the scope is valid
132/// * `Err(CoercionError)` with helpful message if invalid
133pub fn validate_scope(scope: &str) -> Result<(), CoercionError> {
134    const VALID_SCOPES: &[&str] = &["session", "workspace", "global"];
135
136    if VALID_SCOPES.contains(&scope.to_lowercase().as_str()) {
137        Ok(())
138    } else {
139        Err(CoercionError::new(
140            &format!("Invalid scope: '{}'", scope),
141            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid scope"),
142            Some(scope.into()),
143        )
144        .with_parameter_path("scope".to_string())
145        .with_expected_type(&format!("one of: {}", VALID_SCOPES.join(", ")))
146        .with_hint("Valid scopes: 'session' (requires scope_id), 'workspace' (requires scope_id), 'global' (default, no scope_id needed)"))
147    }
148}
149
150/// Validate a session action.
151///
152/// # Arguments
153///
154/// * `action` - The action string to validate
155///
156/// # Valid Values
157///
158/// - `create`: Create a new session
159/// - `list`: List all sessions
160///
161/// # Returns
162///
163/// * `Ok(())` if the action is valid
164/// * `Err(CoercionError)` with helpful message if invalid
165pub fn validate_session_action(action: &str) -> Result<(), CoercionError> {
166    const VALID_ACTIONS: &[&str] = &[
167        "create",
168        "list",
169        "load",
170        "search",
171        "update_metadata",
172        "delete",
173    ];
174
175    if VALID_ACTIONS.contains(&action.to_lowercase().as_str()) {
176        Ok(())
177    } else {
178        Err(CoercionError::new(
179            &format!("Invalid action: '{}'", action),
180            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid action"),
181            Some(action.into()),
182        )
183        .with_parameter_path("action".to_string())
184        .with_expected_type(&format!("one of: {}", VALID_ACTIONS.join(", ")))
185        .with_hint("Valid actions: create (name, description), list, load (session_id), search (query), update_metadata (session_id + name/description), delete (session_id)"))
186    }
187}
188
189/// Validate a workspace action.
190///
191/// # Arguments
192///
193/// * `action` - The action string to validate
194///
195/// # Valid Values
196///
197/// - `create`: Create a new workspace
198/// - `list`: List all workspaces
199/// - `get`: Get workspace details
200/// - `delete`: Delete a workspace
201/// - `add_session`: Add a session to a workspace
202/// - `remove_session`: Remove a session from a workspace
203///
204/// # Returns
205///
206/// * `Ok(())` if the action is valid
207/// * `Err(CoercionError)` with helpful message if invalid
208pub fn validate_workspace_action(action: &str) -> Result<(), CoercionError> {
209    const VALID_ACTIONS: &[&str] = &[
210        "create",
211        "list",
212        "get",
213        "delete",
214        "add_session",
215        "remove_session",
216    ];
217
218    if VALID_ACTIONS.contains(&action.to_lowercase().as_str()) {
219        Ok(())
220    } else {
221        Err(CoercionError::new(
222            &format!("Invalid action: '{}'", action),
223            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid action"),
224            Some(action.into()),
225        )
226        .with_parameter_path("action".to_string())
227        .with_expected_type(&format!("one of: {}", VALID_ACTIONS.join(", ")))
228        .with_hint("Valid actions: create (with name/description), list, get (workspace_id), delete (workspace_id), add_session (workspace_id, session_id, role), remove_session (workspace_id, session_id)"))
229    }
230}
231
232/// Validate a session role for workspace membership.
233///
234/// # Arguments
235///
236/// * `role` - The role string to validate
237///
238/// # Valid Values
239///
240/// - `primary`: Primary session for the workspace
241/// - `related`: Related session
242/// - `dependency`: Dependency session
243/// - `shared`: Shared session
244///
245/// # Returns
246///
247/// * `Ok(())` if the role is valid
248/// * `Err(CoercionError)` with helpful message if invalid
249pub fn validate_session_role(role: &str) -> Result<(), CoercionError> {
250    const VALID_ROLES: &[&str] = &["primary", "related", "dependency", "shared"];
251
252    if VALID_ROLES.contains(&role.to_lowercase().as_str()) {
253        Ok(())
254    } else {
255        Err(CoercionError::new(
256            &format!("Invalid role: '{}'", role),
257            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid role"),
258            Some(role.into()),
259        )
260        .with_parameter_path("role".to_string())
261        .with_expected_type(&format!("one of: {}", VALID_ROLES.join(", ")))
262        .with_hint("Valid session roles: primary (main session), related (related context), dependency (required context), shared (shared context)"))
263    }
264}
265
266/// Validate a recency_bias parameter is within acceptable bounds.
267///
268/// The recency_bias parameter controls temporal decay in semantic search. Invalid values
269/// can cause exponential growth (negatives), underflow to zero (extreme values), or
270/// corrupt rankings (NaN, Infinity).
271///
272/// # Arguments
273///
274/// * `recency_bias` - The recency_bias value to validate (None means disabled)
275///
276/// # Returns
277///
278/// * `Ok(Some(value))` - The validated recency_bias value
279/// * `Ok(None)` - If recency_bias was None (disabled)
280/// * `Err(CoercionError)` with helpful message if invalid
281///
282/// # Valid Range
283///
284/// - `[0.0, 10.0]` - 0.0 disables decay, 10.0 is maximum practical decay rate
285/// - Rejects: negative values, NaN, Infinity, NegInfinity
286///
287/// # Example
288///
289/// ```rust,ignore
290/// use post_cortex::daemon::validate::validate_recency_bias;
291///
292/// // Valid values
293/// assert_eq!(validate_recency_bias(Some(0.0)).unwrap(), Some(0.0));
294/// assert_eq!(validate_recency_bias(Some(0.5)).unwrap(), Some(0.5));
295/// assert_eq!(validate_recency_bias(Some(10.0)).unwrap(), Some(10.0));
296/// assert_eq!(validate_recency_bias(None).unwrap(), None);
297///
298/// // Invalid values
299/// assert!(validate_recency_bias(Some(-1.0)).is_err());
300/// assert!(validate_recency_bias(Some(f32::NAN)).is_err());
301/// assert!(validate_recency_bias(Some(f32::INFINITY)).is_err());
302/// ```
303pub fn validate_recency_bias(recency_bias: Option<f32>) -> Result<Option<f32>, CoercionError> {
304    const MAX_RECENCY_BIAS: f32 = 10.0;
305    const MIN_RECENCY_BIAS: f32 = 0.0;
306
307    if let Some(value) = recency_bias {
308        if value.is_nan() || value.is_infinite() {
309            return Err(CoercionError::new(
310                "Invalid recency_bias value",
311                std::io::Error::new(std::io::ErrorKind::InvalidInput, "NaN or Infinity not allowed"),
312                Some(serde_json::Value::String(value.to_string())),
313            )
314            .with_parameter_path("recency_bias".to_string())
315            .with_expected_type("finite f32 between 0.0 and 10.0")
316            .with_hint("Use a finite value between 0.0 (disabled) and 10.0 (aggressive decay). Recommended: 0.0-1.0 for most use cases."));
317        }
318        if !(MIN_RECENCY_BIAS..=MAX_RECENCY_BIAS).contains(&value) {
319            return Err(CoercionError::new(
320                "recency_bias out of range",
321                std::io::Error::new(
322                    std::io::ErrorKind::InvalidInput,
323                    "Value must be between 0.0 and 10.0",
324                ),
325                Some(serde_json::Value::String(value.to_string())),
326            )
327            .with_parameter_path("recency_bias".to_string())
328            .with_expected_type("f32 in range [0.0, 10.0]")
329            .with_hint(&format!(
330                "Use recency_bias between {} and {}, or omit for default (0.0 = disabled)",
331                MIN_RECENCY_BIAS, MAX_RECENCY_BIAS
332            )));
333        }
334        Ok(Some(value))
335    } else {
336        Ok(None)
337    }
338}
339
340/// Validate a numeric limit is within acceptable bounds.
341///
342/// This function validates that limit parameters are within safe ranges to prevent
343/// excessive resource consumption while maintaining flexibility for legitimate use cases.
344///
345/// # Arguments
346///
347/// * `limit` - The limit value to validate (None means use default)
348/// * `default` - The default limit value (used when limit is None)
349/// * `max` - The maximum allowed limit value
350///
351/// # Returns
352///
353/// * `Ok(limit_value)` - The limit to use (either the provided value or default)
354/// * `Err(CoercionError)` with helpful message if limit exceeds maximum or is zero
355///
356/// # Example
357///
358/// ```rust,ignore
359/// use post_cortex::daemon::validate::validate_limits;
360///
361/// // Valid limit
362/// assert_eq!(validate_limits(Some(5), 10, 100).unwrap(), 5);
363///
364/// // Use default
365/// assert_eq!(validate_limits(None, 10, 100).unwrap(), 10);
366///
367/// // Exceeds maximum
368/// assert!(validate_limits(Some(200), 10, 100).is_err());
369/// ```
370pub fn validate_limits(
371    limit: Option<usize>,
372    default: usize,
373    max: usize,
374) -> Result<usize, CoercionError> {
375    let value = limit.unwrap_or(default);
376    if value > max {
377        Err(CoercionError::new(
378            &format!("Limit {} exceeds maximum of {}", value, max),
379            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Limit too large"),
380            Some(value.into()),
381        )
382        .with_parameter_path("limit".to_string())
383        .with_expected_type(&format!("number between 1 and {}", max))
384        .with_hint(&format!(
385            "Use limit between 1 and {}, or omit for default ({})",
386            max, default
387        )))
388    } else if value == 0 {
389        Err(CoercionError::new(
390            "Limit must be at least 1",
391            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Limit too small"),
392            Some(value.into()),
393        )
394        .with_parameter_path("limit".to_string())
395        .with_expected_type(&format!("number between 1 and {}", max))
396        .with_hint(&format!(
397            "Use limit between 1 and {}, or omit for default ({})",
398            max, default
399        )))
400    } else {
401        Ok(value)
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_validate_session_id_valid() {
411        assert!(validate_session_id("60c598e2-d602-4e07-a328-c458006d48c7").is_ok());
412    }
413
414    #[test]
415    fn test_validate_session_id_invalid() {
416        let result = validate_session_id("invalid");
417        assert!(result.is_err());
418        let error = result.unwrap_err();
419        assert_eq!(error.parameter_path, Some("session_id".to_string()));
420        assert!(error.hint.is_some());
421    }
422
423    #[test]
424    fn test_validate_workspace_id_valid() {
425        assert!(validate_workspace_id("60c598e2-d602-4e07-a328-c458006d48c7").is_ok());
426    }
427
428    #[test]
429    fn test_validate_workspace_id_invalid() {
430        let result = validate_workspace_id("not-a-uuid");
431        assert!(result.is_err());
432        let error = result.unwrap_err();
433        assert_eq!(error.parameter_path, Some("workspace_id".to_string()));
434    }
435
436    #[test]
437    fn test_validate_interaction_type_valid() {
438        assert!(validate_interaction_type("qa").is_ok());
439        assert!(validate_interaction_type("decision_made").is_ok());
440        assert!(validate_interaction_type("problem_solved").is_ok());
441        assert!(validate_interaction_type("code_change").is_ok());
442        assert!(validate_interaction_type("requirement_added").is_ok());
443        assert!(validate_interaction_type("concept_defined").is_ok());
444    }
445
446    #[test]
447    fn test_validate_interaction_type_invalid() {
448        let result = validate_interaction_type("made_decision");
449        assert!(result.is_err());
450        let error = result.unwrap_err();
451        assert_eq!(error.parameter_path, Some("interaction_type".to_string()));
452        assert!(error.hint.unwrap().contains("decision_made"));
453    }
454
455    #[test]
456    fn test_validate_scope_valid() {
457        assert!(validate_scope("session").is_ok());
458        assert!(validate_scope("workspace").is_ok());
459        assert!(validate_scope("global").is_ok());
460    }
461
462    #[test]
463    fn test_validate_scope_invalid() {
464        let result = validate_scope("invalid_scope");
465        assert!(result.is_err());
466        let error = result.unwrap_err();
467        assert_eq!(error.parameter_path, Some("scope".to_string()));
468    }
469
470    #[test]
471    fn test_validate_session_action_valid() {
472        assert!(validate_session_action("create").is_ok());
473        assert!(validate_session_action("list").is_ok());
474    }
475
476    #[test]
477    fn test_validate_session_action_invalid() {
478        // "delete" was added to VALID_ACTIONS in a later refactor; the
479        // test previously asserted that "delete" was rejected, which
480        // became the long-standing baseline failure noted in TODO.md.
481        // Use a genuinely invalid action here.
482        let result = validate_session_action("nuke_everything");
483        assert!(result.is_err());
484        let error = result.unwrap_err();
485        let hint = error.hint.as_ref().unwrap();
486        assert!(hint.contains("create") && hint.contains("list"));
487    }
488
489    #[test]
490    fn test_validate_workspace_action_valid() {
491        assert!(validate_workspace_action("create").is_ok());
492        assert!(validate_workspace_action("list").is_ok());
493        assert!(validate_workspace_action("get").is_ok());
494        assert!(validate_workspace_action("delete").is_ok());
495        assert!(validate_workspace_action("add_session").is_ok());
496        assert!(validate_workspace_action("remove_session").is_ok());
497    }
498
499    #[test]
500    fn test_validate_workspace_action_invalid() {
501        let result = validate_workspace_action("invalid_action");
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn test_validate_session_role_valid() {
507        assert!(validate_session_role("primary").is_ok());
508        assert!(validate_session_role("related").is_ok());
509        assert!(validate_session_role("dependency").is_ok());
510        assert!(validate_session_role("shared").is_ok());
511    }
512
513    #[test]
514    fn test_validate_session_role_invalid() {
515        let result = validate_session_role("admin");
516        assert!(result.is_err());
517        let error = result.unwrap_err();
518        assert_eq!(error.parameter_path, Some("role".to_string()));
519        assert!(error.hint.unwrap().contains("primary"));
520    }
521
522    #[test]
523    fn test_validate_limits_within_bounds() {
524        assert_eq!(validate_limits(Some(5), 10, 100).unwrap(), 5);
525        assert_eq!(validate_limits(Some(10), 10, 100).unwrap(), 10);
526        assert_eq!(validate_limits(Some(100), 10, 100).unwrap(), 100);
527    }
528
529    #[test]
530    fn test_validate_limits_use_default() {
531        assert_eq!(validate_limits(None, 10, 100).unwrap(), 10);
532        assert_eq!(validate_limits(None, 50, 100).unwrap(), 50);
533    }
534
535    #[test]
536    fn test_validate_limits_exceeds_maximum() {
537        let result = validate_limits(Some(200), 10, 100);
538        assert!(result.is_err());
539        let error = result.unwrap_err();
540        assert_eq!(error.parameter_path, Some("limit".to_string()));
541        assert!(error.message.contains("exceeds maximum"));
542    }
543
544    #[test]
545    fn test_validate_limits_zero() {
546        let result = validate_limits(Some(0), 10, 100);
547        assert!(result.is_err());
548        let error = result.unwrap_err();
549        assert!(error.message.contains("at least 1"));
550    }
551
552    #[test]
553    fn test_validate_recency_bias_valid() {
554        // Valid values
555        assert_eq!(validate_recency_bias(Some(0.0)).unwrap(), Some(0.0));
556        assert_eq!(validate_recency_bias(Some(0.5)).unwrap(), Some(0.5));
557        assert_eq!(validate_recency_bias(Some(1.0)).unwrap(), Some(1.0));
558        assert_eq!(validate_recency_bias(Some(10.0)).unwrap(), Some(10.0));
559    }
560
561    #[test]
562    fn test_validate_recency_bias_none() {
563        assert_eq!(validate_recency_bias(None).unwrap(), None);
564    }
565
566    #[test]
567    fn test_validate_recency_bias_negative() {
568        let result = validate_recency_bias(Some(-1.0));
569        assert!(result.is_err());
570        let error = result.unwrap_err();
571        assert_eq!(error.parameter_path, Some("recency_bias".to_string()));
572        assert!(error.message.contains("out of range"));
573    }
574
575    #[test]
576    fn test_validate_recency_bias_nan() {
577        let result = validate_recency_bias(Some(f32::NAN));
578        assert!(result.is_err());
579        let error = result.unwrap_err();
580        assert_eq!(error.parameter_path, Some("recency_bias".to_string()));
581        assert!(error.message.contains("Invalid recency_bias value"));
582    }
583
584    #[test]
585    fn test_validate_recency_bias_infinity() {
586        let result = validate_recency_bias(Some(f32::INFINITY));
587        assert!(result.is_err());
588        let error = result.unwrap_err();
589        assert!(error.message.contains("NaN or Infinity not allowed"));
590    }
591
592    #[test]
593    fn test_validate_recency_bias_exceeds_maximum() {
594        let result = validate_recency_bias(Some(100.0));
595        assert!(result.is_err());
596        let error = result.unwrap_err();
597        assert!(error.message.contains("out of range"));
598        assert_eq!(error.parameter_path, Some("recency_bias".to_string()));
599    }
600}