Skip to main content

ralph_workflow/agents/ccs/
parsing.rs

1// CCS output parsing logic - parsing Claude Code responses, extracting structured data
2
3/// CCS alias prefix for agent names.
4pub const CCS_PREFIX: &str = "ccs/";
5
6/// Parse a CCS agent reference and extract the alias name.
7///
8/// Returns `Some(alias)` if the agent name matches `ccs/alias` pattern,
9/// or `Some("")` if it's just `ccs` (for default profile).
10/// Returns `None` if the name doesn't match the CCS pattern.
11///
12/// # Examples
13///
14/// ```ignore
15/// assert_eq!(parse_ccs_ref("ccs/work"), Some("work"));
16/// assert_eq!(parse_ccs_ref("ccs"), Some(""));
17/// assert_eq!(parse_ccs_ref("claude"), None);
18/// ```
19#[must_use]
20pub fn parse_ccs_ref(agent_name: &str) -> Option<&str> {
21    if agent_name == "ccs" {
22        Some("")
23    } else if let Some(alias) = agent_name.strip_prefix(CCS_PREFIX) {
24        Some(alias)
25    } else {
26        None
27    }
28}
29
30/// Check if an agent name is a CCS reference.
31#[must_use]
32pub fn is_ccs_ref(agent_name: &str) -> bool {
33    parse_ccs_ref(agent_name).is_some()
34}
35
36/// Check if a command appears to be the CCS executable.
37///
38/// This is a heuristic check based on the file name of the command.
39/// Returns `true` if the file name is `ccs` or `ccs.exe`.
40fn looks_like_ccs_executable(cmd0: &str) -> bool {
41    Path::new(cmd0)
42        .file_name()
43        .and_then(|n| n.to_str())
44        .is_some_and(|n| n == "ccs" || n == "ccs.exe")
45}
46
47/// Extract the CCS profile name from a CCS command.
48///
49/// Parses a CCS command string to extract the profile name.
50/// Supports common patterns like `ccs <profile>` and `ccs api <profile>`.
51///
52/// Returns `Some(profile_name)` if a profile is found, `None` otherwise.
53pub(super) fn ccs_profile_from_command(original_cmd: &str) -> Option<String> {
54    let parts = split_command(original_cmd).ok()?;
55    if !parts.first().is_some_and(|p| looks_like_ccs_executable(p)) {
56        return None;
57    }
58    // Common patterns:
59    // - `ccs <profile>`
60    // - `ccs api <profile>`
61    if parts.get(1).is_some_and(|p| p == "api") {
62        parts.get(2).cloned()
63    } else {
64        parts.get(1).cloned()
65    }
66}
67
68fn choose_best_profile_guess<'a>(input: &str, suggestions: &'a [String]) -> Option<&'a str> {
69    if suggestions.is_empty() {
70        return None;
71    }
72    let input_lower = input.to_lowercase();
73    if let Some(exact) = suggestions
74        .iter()
75        .find(|s| s.to_lowercase() == input_lower)
76        .map(std::string::String::as_str)
77    {
78        return Some(exact);
79    }
80    if suggestions.len() == 1 {
81        return Some(suggestions.first()?.as_str());
82    }
83    if let Some(starts) = suggestions
84        .iter()
85        .find(|s| s.to_lowercase().starts_with(&input_lower))
86        .map(std::string::String::as_str)
87    {
88        return Some(starts);
89    }
90    Some(suggestions.first()?.as_str())
91}
92
93pub(super) fn load_ccs_env_vars_with_guess(
94    profile: &str,
95) -> Result<(HashMap<String, String>, Option<String>), CcsEnvVarsError> {
96    match load_ccs_env_vars(profile) {
97        Ok(vars) => Ok((vars, None)),
98        Err(err @ CcsEnvVarsError::ProfileNotFound { .. }) => {
99            let suggestions = find_ccs_profile_suggestions(profile);
100            let Some(best) = choose_best_profile_guess(profile, &suggestions) else {
101                return Err(err);
102            };
103            let vars = load_ccs_env_vars(best)?;
104            Ok((vars, Some(best.to_string())))
105        }
106        Err(err) => Err(err),
107    }
108}
109
110#[cfg(test)]
111mod proptest_parsers {
112    use super::{parse_ccs_ref, CCS_PREFIX};
113    use proptest::prelude::*;
114
115    proptest! {
116        /// `parse_ccs_ref` must never panic on any string input.
117        #[test]
118        fn parse_ccs_ref_never_panics(s in ".*") {
119            let _ = parse_ccs_ref(&s);
120        }
121
122        /// Exact `"ccs"` always returns `Some("")`.
123        #[test]
124        fn parse_ccs_ref_exact_ccs_returns_empty(s in Just("ccs".to_string())) {
125            prop_assert_eq!(parse_ccs_ref(&s), Some(""));
126        }
127
128        /// A name starting with `"ccs/"` followed by a non-empty alias returns `Some(alias)`.
129        #[test]
130        fn parse_ccs_ref_ccs_slash_alias_returns_alias(alias in "[a-zA-Z][a-zA-Z0-9_-]{0,20}") {
131            let name = format!("{CCS_PREFIX}{alias}");
132            let result = parse_ccs_ref(&name);
133            prop_assert_eq!(result, Some(alias.as_str()));
134        }
135
136        /// A name not starting with `"ccs"` always returns `None`.
137        #[test]
138        fn parse_ccs_ref_non_ccs_returns_none(s in "[^c].*|c[^c].*|cc[^s].*") {
139            prop_assert_eq!(parse_ccs_ref(&s), None);
140        }
141    }
142}