Skip to main content

osp_cli/dsl/parse/
pipeline.rs

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