Skip to main content

osp_cli/dsl/parse/
pipeline.rs

1use crate::dsl::model::{ParsedPipeline, ParsedStage, ParsedStageKind};
2use crate::dsl::verb_info::is_registered_explicit_verb;
3
4use super::lexer::{LexerError, StageSegment, split_pipeline, tokenize_stage};
5
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
7/// Parsed command line split into a command segment and trailing DSL stages.
8pub struct Pipeline {
9    /// Raw command segment before pipe stages.
10    pub command: String,
11    /// Raw DSL stages in left-to-right execution order.
12    pub stages: Vec<String>,
13}
14
15/// Split a full command line into its command portion and raw pipe stages.
16pub fn parse_pipeline(line: &str) -> Result<Pipeline, LexerError> {
17    let segments = split_pipeline(line)?;
18
19    let command = segments
20        .first()
21        .map(|segment| segment.raw.clone())
22        .unwrap_or_default();
23
24    let stages = if segments.len() > 1 {
25        segments[1..]
26            .iter()
27            .map(|segment| segment.raw.clone())
28            .collect()
29    } else {
30        Vec::new()
31    };
32
33    Ok(Pipeline { command, stages })
34}
35
36/// Parse a raw stage string into the structured form the evaluator consumes.
37///
38/// This is intentionally conservative:
39/// - registered verbs become explicit stages
40/// - unknown one-letter tokens are treated as likely typos and fail later
41/// - everything else becomes quick-search text
42pub fn parse_stage(raw_stage: &str) -> Result<ParsedStage, LexerError> {
43    let segment = stage_segment_from_raw(raw_stage);
44
45    if segment.raw.is_empty() {
46        return Ok(empty_quick_stage(raw_stage));
47    }
48
49    let tokens = tokenize_stage(&segment)?;
50
51    let Some(first) = tokens.first() else {
52        return Ok(empty_quick_stage(raw_stage));
53    };
54
55    let verb = first.text.to_ascii_uppercase();
56    let spec = stage_spec_after_first_token(&segment, first.span.end);
57
58    Ok(ParsedStage::new(
59        classify_stage_kind(&verb),
60        verb,
61        spec,
62        segment.raw,
63    ))
64}
65
66/// Parse an already-split list of stage strings.
67pub fn parse_stage_list(stages: &[String]) -> Result<ParsedPipeline, LexerError> {
68    Ok(ParsedPipeline {
69        raw: stages.join(" | "),
70        stages: stages
71            .iter()
72            .map(|stage| parse_stage(stage))
73            .collect::<Result<Vec<_>, _>>()?,
74    })
75}
76
77fn stage_segment_from_raw(raw_stage: &str) -> StageSegment {
78    let trimmed = raw_stage.trim();
79    StageSegment {
80        raw: trimmed.to_string(),
81        span: super::lexer::Span {
82            start: 0,
83            end: trimmed.len(),
84        },
85    }
86}
87
88fn empty_quick_stage(raw_stage: &str) -> ParsedStage {
89    ParsedStage::new(ParsedStageKind::Quick, "", "", raw_stage)
90}
91
92fn stage_spec_after_first_token(segment: &StageSegment, token_end: usize) -> String {
93    if token_end > segment.raw.len() {
94        return String::new();
95    }
96    segment.raw[token_end..].trim().to_string()
97}
98
99fn classify_stage_kind(verb: &str) -> ParsedStageKind {
100    if is_registered_explicit_verb(verb) {
101        return ParsedStageKind::Explicit;
102    }
103
104    if verb.len() == 1 && verb.chars().all(|ch| ch.is_ascii_alphabetic()) {
105        return ParsedStageKind::UnknownExplicit;
106    }
107
108    ParsedStageKind::Quick
109}
110
111#[cfg(test)]
112mod tests {
113    use crate::dsl::model::ParsedStageKind;
114
115    use super::{LexerError, parse_pipeline, parse_stage, parse_stage_list};
116
117    #[test]
118    fn parse_pipeline_extracts_command_and_stages() {
119        let parsed =
120            parse_pipeline("ldap user oistes | P uid,cn | F uid=oistes").expect("valid pipeline");
121        assert_eq!(parsed.command, "ldap user oistes");
122        assert_eq!(parsed.stages, vec!["P uid,cn", "F uid=oistes"]);
123    }
124
125    #[test]
126    fn parse_pipeline_ignores_empty_segments_like_python() {
127        let parsed =
128            parse_pipeline("ldap user oistes || P uid |  | F uid=oistes").expect("valid pipeline");
129        assert_eq!(parsed.command, "ldap user oistes");
130        assert_eq!(parsed.stages, vec!["P uid", "F uid=oistes"]);
131    }
132
133    #[test]
134    fn parse_pipeline_rejects_invalid_quotes() {
135        let err =
136            parse_pipeline("ldap user 'oops | P uid").expect_err("invalid quotes should fail");
137        assert!(matches!(err, LexerError::UnterminatedSingleQuote { .. }));
138    }
139
140    #[test]
141    fn parse_pipeline_rejects_trailing_escape() {
142        let err = parse_pipeline("ldap user foo\\").expect_err("trailing escape should fail");
143        assert!(matches!(err, LexerError::TrailingEscape { .. }));
144    }
145
146    #[test]
147    fn parse_stage_extracts_verb_and_spec() {
148        let parsed = parse_stage("F uid=oistes").expect("stage should parse");
149        assert_eq!(parsed.kind, ParsedStageKind::Explicit);
150        assert_eq!(parsed.verb, "F");
151        assert_eq!(parsed.spec, "uid=oistes");
152    }
153
154    #[test]
155    fn parse_stage_with_only_term_becomes_quick_candidate() {
156        let parsed = parse_stage("uid").expect("stage should parse");
157        assert_eq!(parsed.kind, ParsedStageKind::Quick);
158        assert_eq!(parsed.verb, "UID");
159        assert_eq!(parsed.spec, "");
160    }
161
162    #[test]
163    fn parse_stage_marks_unknown_single_letter_verb_as_explicit() {
164        let parsed = parse_stage("R oist").expect("stage should parse");
165        assert_eq!(parsed.kind, ParsedStageKind::UnknownExplicit);
166        assert_eq!(parsed.verb, "R");
167        assert_eq!(parsed.spec, "oist");
168    }
169
170    #[test]
171    fn parse_stage_list_rejects_invalid_quoted_stage() {
172        let err = parse_stage_list(&[r#"F note="oops"#.to_string()])
173            .expect_err("invalid quotes should fail");
174        assert!(matches!(err, LexerError::UnterminatedDoubleQuote { .. }));
175    }
176
177    #[test]
178    fn parse_stage_list_rejects_trailing_escape() {
179        let err = parse_stage_list(&["F path=C:\\Temp\\".to_string()])
180            .expect_err("trailing escape should fail");
181        assert!(matches!(err, LexerError::TrailingEscape { .. }));
182    }
183}