Skip to main content

oximedia_graph/
dsl.rs

1//! Graph DSL parser for describing filter pipelines as text.
2//!
3//! This module provides a simple text-based domain-specific language for
4//! defining media processing filter graphs.  The DSL is designed to be
5//! human-readable and easy to produce programmatically.
6//!
7//! # Syntax
8//!
9//! A graph description is a sequence of **pipeline chains** separated by
10//! semicolons (`;`) or newlines.  Each chain is a sequence of **node
11//! specifications** connected by arrow tokens (`->`).
12//!
13//! ```text
14//! source -> scale(1920,1080) -> encoder -> sink
15//! source_audio -> normalize -> aac_encoder -> mux
16//! ```
17//!
18//! A **node specification** has the form:
19//!
20//! ```text
21//! name
22//! name(arg1, arg2, …)
23//! label:name
24//! label:name(arg1, arg2, …)
25//! ```
26//!
27//! Where:
28//! - `name` is an ASCII identifier (letters, digits, underscores, hyphens).
29//! - `label` is an optional unique alias used to identify the node when
30//!   building the graph (useful when the same filter type is used more than
31//!   once).
32//! - Arguments (`arg1`, `arg2`, …) are positional string tokens passed to the
33//!   filter constructor.  Quoted strings (`"…"`) preserve embedded spaces.
34//!
35//! ## Multi-branch graphs
36//!
37//! Fan-out and fan-in topologies require explicit node labels.  The same label
38//! can appear in multiple chains to express shared nodes:
39//!
40//! ```text
41//! source -> split
42//! split -> scale(1280,720) -> sink_hd
43//! split -> scale(640,360) -> sink_sd
44//! ```
45//!
46//! ## Comments
47//!
48//! Lines that start with `#` (after optional whitespace) are ignored.
49//!
50//! # Example
51//!
52//! ```
53//! use oximedia_graph::dsl::{parse_graph_dsl, GraphDescription};
54//!
55//! let input = "source -> scale(1920,1080) -> encoder -> sink";
56//! let desc = parse_graph_dsl(input).expect("parse should succeed");
57//!
58//! // Four nodes and three edges
59//! assert_eq!(desc.nodes.len(), 4);
60//! assert_eq!(desc.edges.len(), 3);
61//! ```
62
63#![forbid(unsafe_code)]
64#![allow(dead_code)]
65#![allow(clippy::missing_errors_doc)]
66
67use std::fmt;
68
69use crate::error::{GraphError, GraphResult};
70
71// ─────────────────────────────────────────────────────────────────────────────
72// Public types
73// ─────────────────────────────────────────────────────────────────────────────
74
75/// A parsed description of a filter graph.
76///
77/// Produced by [`parse_graph_dsl`].
78#[derive(Debug, Clone, PartialEq)]
79pub struct GraphDescription {
80    /// Unique node specifications, deduplicated by their label.
81    pub nodes: Vec<NodeSpec>,
82    /// Directed edges between node labels.
83    pub edges: Vec<EdgeSpec>,
84}
85
86impl GraphDescription {
87    /// Returns `true` if the graph has no nodes.
88    #[must_use]
89    pub fn is_empty(&self) -> bool {
90        self.nodes.is_empty()
91    }
92
93    /// Returns `true` if a node with the given label exists.
94    #[must_use]
95    pub fn contains_node(&self, label: &str) -> bool {
96        self.nodes.iter().any(|n| n.label == label)
97    }
98
99    /// Look up a node by label.
100    #[must_use]
101    pub fn node(&self, label: &str) -> Option<&NodeSpec> {
102        self.nodes.iter().find(|n| n.label == label)
103    }
104}
105
106/// Specification of a single node in the graph.
107#[derive(Debug, Clone, PartialEq)]
108pub struct NodeSpec {
109    /// Unique label used to identify this node in edges.
110    ///
111    /// If the DSL input did not provide an explicit `label:name` prefix the
112    /// label is synthesised from the filter name and an auto-increment counter
113    /// (e.g. `scale_0`, `scale_1`).
114    pub label: String,
115    /// Filter type name (the identifier before any argument list).
116    pub filter: String,
117    /// Positional arguments provided in the parenthesised argument list.
118    pub args: Vec<String>,
119}
120
121impl NodeSpec {
122    /// Create a node specification with no arguments.
123    #[must_use]
124    pub fn new(label: impl Into<String>, filter: impl Into<String>) -> Self {
125        Self {
126            label: label.into(),
127            filter: filter.into(),
128            args: Vec::new(),
129        }
130    }
131
132    /// Create a node specification with arguments.
133    #[must_use]
134    pub fn with_args(
135        label: impl Into<String>,
136        filter: impl Into<String>,
137        args: Vec<String>,
138    ) -> Self {
139        Self {
140            label: label.into(),
141            filter: filter.into(),
142            args,
143        }
144    }
145}
146
147impl fmt::Display for NodeSpec {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "{}:{}", self.label, self.filter)?;
150        if !self.args.is_empty() {
151            write!(f, "({})", self.args.join(", "))?;
152        }
153        Ok(())
154    }
155}
156
157/// A directed edge from one node to another.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct EdgeSpec {
160    /// Label of the source node.
161    pub from: String,
162    /// Label of the destination node.
163    pub to: String,
164}
165
166impl EdgeSpec {
167    /// Create a new edge specification.
168    #[must_use]
169    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
170        Self {
171            from: from.into(),
172            to: to.into(),
173        }
174    }
175}
176
177impl fmt::Display for EdgeSpec {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        write!(f, "{} -> {}", self.from, self.to)
180    }
181}
182
183// ─────────────────────────────────────────────────────────────────────────────
184// Parse error
185// ─────────────────────────────────────────────────────────────────────────────
186
187/// A parse error with position information.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct ParseError {
190    /// Human-readable description of the problem.
191    pub message: String,
192    /// 1-based line number where the error occurred (if known).
193    pub line: Option<usize>,
194    /// 1-based column number where the error occurred (if known).
195    pub column: Option<usize>,
196}
197
198impl ParseError {
199    fn at(line: usize, column: usize, message: impl Into<String>) -> Self {
200        Self {
201            message: message.into(),
202            line: Some(line),
203            column: Some(column),
204        }
205    }
206
207    fn simple(message: impl Into<String>) -> Self {
208        Self {
209            message: message.into(),
210            line: None,
211            column: None,
212        }
213    }
214}
215
216impl fmt::Display for ParseError {
217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218        match (self.line, self.column) {
219            (Some(l), Some(c)) => write!(f, "parse error at {}:{}: {}", l, c, self.message),
220            (Some(l), None) => write!(f, "parse error at line {}: {}", l, self.message),
221            _ => write!(f, "parse error: {}", self.message),
222        }
223    }
224}
225
226impl From<ParseError> for GraphError {
227    fn from(e: ParseError) -> Self {
228        GraphError::ConfigurationError(e.to_string())
229    }
230}
231
232// ─────────────────────────────────────────────────────────────────────────────
233// Tokeniser
234// ─────────────────────────────────────────────────────────────────────────────
235
236/// Token kinds produced by the tokeniser.
237#[derive(Debug, Clone, PartialEq, Eq, Hash)]
238enum TokenKind {
239    /// An identifier or bare argument value.
240    Ident(String),
241    /// A quoted string argument.
242    Quoted(String),
243    /// The `->` arrow connecting nodes.
244    Arrow,
245    /// `(` opening an argument list.
246    LParen,
247    /// `)` closing an argument list.
248    RParen,
249    /// `,` separating arguments.
250    Comma,
251    /// `:` separating a label from a filter name.
252    Colon,
253    /// `;` or newline — chain separator.
254    ChainSep,
255}
256
257#[derive(Debug, Clone)]
258struct Token {
259    kind: TokenKind,
260    line: usize,
261    col: usize,
262}
263
264/// Tokenise `input` into a flat `Vec<Token>`.
265///
266/// Lines starting with `#` (after optional leading whitespace) are treated as
267/// comments and skipped entirely.
268fn tokenise(input: &str) -> Result<Vec<Token>, ParseError> {
269    let mut tokens = Vec::new();
270    let mut chars = input.char_indices().peekable();
271    let mut line = 1usize;
272    let mut line_start = 0usize;
273
274    while let Some(&(idx, ch)) = chars.peek() {
275        let col = idx - line_start + 1;
276
277        match ch {
278            // Skip spaces and tabs.
279            ' ' | '\t' | '\r' => {
280                chars.next();
281            }
282
283            // Newline — chain separator (unless it's just whitespace between tokens).
284            '\n' => {
285                chars.next();
286                // Emit ChainSep only when there are tokens already queued
287                // (avoids leading separators from blank lines).
288                if !tokens.is_empty() {
289                    // Don't emit a duplicate ChainSep.
290                    let last_is_sep = matches!(
291                        tokens.last().map(|t: &Token| &t.kind),
292                        Some(TokenKind::ChainSep)
293                    );
294                    if !last_is_sep {
295                        tokens.push(Token {
296                            kind: TokenKind::ChainSep,
297                            line,
298                            col,
299                        });
300                    }
301                }
302                line += 1;
303                line_start = idx + 1;
304            }
305
306            // Comment — skip until end of line.
307            '#' => {
308                while let Some(&(_, c)) = chars.peek() {
309                    if c == '\n' {
310                        break;
311                    }
312                    chars.next();
313                }
314            }
315
316            // `->`  arrow  OR  negative number literal (e.g. `-14`).
317            '-' => {
318                chars.next();
319                match chars.peek() {
320                    Some(&(_, '>')) => {
321                        chars.next();
322                        tokens.push(Token {
323                            kind: TokenKind::Arrow,
324                            line,
325                            col,
326                        });
327                    }
328                    // Negative integer/float argument: `-` followed by a digit.
329                    Some(&(_, c)) if c.is_ascii_digit() => {
330                        let mut num = String::from('-');
331                        while let Some(&(_, c)) = chars.peek() {
332                            if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
333                                num.push(c);
334                                chars.next();
335                            } else {
336                                break;
337                            }
338                        }
339                        tokens.push(Token {
340                            kind: TokenKind::Ident(num),
341                            line,
342                            col,
343                        });
344                    }
345                    _ => {
346                        return Err(ParseError::at(
347                            line,
348                            col,
349                            "unexpected '-'; did you mean '->'?",
350                        ));
351                    }
352                }
353            }
354
355            '(' => {
356                chars.next();
357                tokens.push(Token {
358                    kind: TokenKind::LParen,
359                    line,
360                    col,
361                });
362            }
363            ')' => {
364                chars.next();
365                tokens.push(Token {
366                    kind: TokenKind::RParen,
367                    line,
368                    col,
369                });
370            }
371            ',' => {
372                chars.next();
373                tokens.push(Token {
374                    kind: TokenKind::Comma,
375                    line,
376                    col,
377                });
378            }
379            ':' => {
380                chars.next();
381                tokens.push(Token {
382                    kind: TokenKind::Colon,
383                    line,
384                    col,
385                });
386            }
387            ';' => {
388                chars.next();
389                let last_is_sep = matches!(
390                    tokens.last().map(|t: &Token| &t.kind),
391                    Some(TokenKind::ChainSep)
392                );
393                if !last_is_sep {
394                    tokens.push(Token {
395                        kind: TokenKind::ChainSep,
396                        line,
397                        col,
398                    });
399                }
400            }
401
402            // Quoted string.
403            '"' => {
404                chars.next();
405                let mut s = String::new();
406                let mut closed = false;
407                while let Some(&(_, c)) = chars.peek() {
408                    chars.next();
409                    if c == '"' {
410                        closed = true;
411                        break;
412                    }
413                    if c == '\\' {
414                        // Escape sequence — consume one more character.
415                        if let Some(&(_, escaped)) = chars.peek() {
416                            chars.next();
417                            match escaped {
418                                'n' => s.push('\n'),
419                                't' => s.push('\t'),
420                                '"' => s.push('"'),
421                                '\\' => s.push('\\'),
422                                other => {
423                                    s.push('\\');
424                                    s.push(other);
425                                }
426                            }
427                        }
428                    } else {
429                        s.push(c);
430                    }
431                }
432                if !closed {
433                    return Err(ParseError::at(line, col, "unterminated string literal"));
434                }
435                tokens.push(Token {
436                    kind: TokenKind::Quoted(s),
437                    line,
438                    col,
439                });
440            }
441
442            // Identifier: letters, digits, '_', '-' (but not '-->' prefix).
443            c if c.is_ascii_alphanumeric() || c == '_' => {
444                let mut ident = String::new();
445                while let Some(&(_, c)) = chars.peek() {
446                    if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
447                        // Avoid consuming the '-' of '->'.
448                        if c == '-' {
449                            // Peek two characters ahead.
450                            let rest: String = {
451                                let mut tmp = chars.clone();
452                                tmp.next(); // consume '-'
453                                tmp.peek().map(|&(_, x)| x).map_or(String::new(), |x| {
454                                    let mut s = String::from('-');
455                                    s.push(x);
456                                    s
457                                })
458                            };
459                            if rest == "->" {
460                                break;
461                            }
462                        }
463                        ident.push(c);
464                        chars.next();
465                    } else {
466                        break;
467                    }
468                }
469                tokens.push(Token {
470                    kind: TokenKind::Ident(ident),
471                    line,
472                    col,
473                });
474            }
475
476            // Unrecognised character.
477            other => {
478                return Err(ParseError::at(
479                    line,
480                    col,
481                    format!("unexpected character '{other}'"),
482                ));
483            }
484        }
485    }
486
487    Ok(tokens)
488}
489
490// ─────────────────────────────────────────────────────────────────────────────
491// Parser
492// ─────────────────────────────────────────────────────────────────────────────
493
494struct Parser {
495    tokens: Vec<Token>,
496    pos: usize,
497    /// Counter used to generate unique labels when none are provided.
498    counters: std::collections::HashMap<String, usize>,
499}
500
501impl Parser {
502    fn new(tokens: Vec<Token>) -> Self {
503        Self {
504            tokens,
505            pos: 0,
506            counters: std::collections::HashMap::new(),
507        }
508    }
509
510    fn peek(&self) -> Option<&Token> {
511        self.tokens.get(self.pos)
512    }
513
514    fn next_token(&mut self) -> Option<&Token> {
515        let t = self.tokens.get(self.pos);
516        self.pos += 1;
517        t
518    }
519
520    /// Skip chain separators; returns `true` if any were consumed.
521    fn skip_seps(&mut self) -> bool {
522        let mut skipped = false;
523        while matches!(self.peek().map(|t| &t.kind), Some(TokenKind::ChainSep)) {
524            self.pos += 1;
525            skipped = true;
526        }
527        skipped
528    }
529
530    /// Generate an auto-label for `filter_name`.
531    fn auto_label(&mut self, filter_name: &str) -> String {
532        let count = self.counters.entry(filter_name.to_owned()).or_insert(0);
533        let label = format!("{}_{}", filter_name, count);
534        *count += 1;
535        label
536    }
537
538    /// Parse `label:filter_name(args…)` or just `filter_name(args…)`.
539    fn parse_node_spec(&mut self) -> Result<NodeSpec, ParseError> {
540        // Peek to decide if next pattern is `ident : ident` (label:name).
541        let label_or_filter = match self.peek() {
542            Some(Token {
543                kind: TokenKind::Ident(s),
544                ..
545            }) => s.clone(),
546            Some(t) => {
547                return Err(ParseError::at(
548                    t.line,
549                    t.col,
550                    format!("expected node name, found {:?}", t.kind),
551                ));
552            }
553            None => {
554                return Err(ParseError::simple("unexpected end of input in node spec"));
555            }
556        };
557        self.pos += 1; // consume the first ident
558
559        // Check if next token is `:` — if so this is `label : filter_name`.
560        let (label, filter) = if matches!(self.peek().map(|t| &t.kind), Some(TokenKind::Colon)) {
561            self.pos += 1; // consume `:`
562            let filter = match self.peek() {
563                Some(Token {
564                    kind: TokenKind::Ident(s),
565                    ..
566                }) => s.clone(),
567                Some(t) => {
568                    return Err(ParseError::at(
569                        t.line,
570                        t.col,
571                        "expected filter name after ':'",
572                    ));
573                }
574                None => {
575                    return Err(ParseError::simple("expected filter name after ':'"));
576                }
577            };
578            self.pos += 1; // consume filter ident
579            (label_or_filter, filter)
580        } else {
581            // No label — derive one from filter name.
582            let filter = label_or_filter.clone();
583            let label = self.auto_label(&filter);
584            (label, filter)
585        };
586
587        // Optionally parse `( arg, arg, … )`.
588        let args = if matches!(self.peek().map(|t| &t.kind), Some(TokenKind::LParen)) {
589            self.pos += 1; // consume `(`
590            self.parse_args()?
591        } else {
592            Vec::new()
593        };
594
595        Ok(NodeSpec {
596            label,
597            filter,
598            args,
599        })
600    }
601
602    /// Parse a comma-separated argument list, consuming the closing `)`.
603    fn parse_args(&mut self) -> Result<Vec<String>, ParseError> {
604        let mut args = Vec::new();
605        loop {
606            // Clone the kind to avoid borrow-checker issues when we mutate pos.
607            let kind_opt = self.peek().map(|t| t.kind.clone());
608            match kind_opt {
609                Some(TokenKind::RParen) => {
610                    self.pos += 1; // consume `)`
611                    break;
612                }
613                Some(TokenKind::Ident(s)) => {
614                    args.push(s);
615                    self.pos += 1;
616                }
617                Some(TokenKind::Quoted(s)) => {
618                    args.push(s);
619                    self.pos += 1;
620                }
621                Some(TokenKind::Comma) => {
622                    self.pos += 1; // skip comma
623                }
624                _ => {
625                    if let Some(t) = self.peek() {
626                        return Err(ParseError::at(
627                            t.line,
628                            t.col,
629                            format!("unexpected token in argument list: {:?}", t.kind),
630                        ));
631                    }
632                    return Err(ParseError::simple("unterminated argument list"));
633                }
634            }
635        }
636        Ok(args)
637    }
638
639    /// Parse one pipeline chain: `node (-> node)*`.
640    fn parse_chain(
641        &mut self,
642        nodes: &mut Vec<NodeSpec>,
643        edges: &mut Vec<EdgeSpec>,
644    ) -> Result<(), ParseError> {
645        let first = self.parse_node_spec()?;
646        let mut prev_label = first.label.clone();
647        if !nodes.iter().any(|n| n.label == first.label) {
648            nodes.push(first);
649        }
650
651        loop {
652            // Clone the kind to avoid borrow conflicts when mutating self.
653            let kind_opt = self.peek().map(|t| t.kind.clone());
654            match kind_opt {
655                Some(TokenKind::Arrow) => {
656                    self.pos += 1; // consume `->`
657                    let next = self.parse_node_spec()?;
658                    let next_label = next.label.clone();
659                    // Add edge.
660                    edges.push(EdgeSpec::new(prev_label.clone(), next_label.clone()));
661                    // Add node only if not already seen.
662                    if !nodes.iter().any(|n| n.label == next.label) {
663                        nodes.push(next);
664                    }
665                    prev_label = next_label;
666                }
667                // Chain ended.
668                Some(TokenKind::ChainSep) | None => {
669                    break;
670                }
671                _ => {
672                    if let Some(t) = self.peek() {
673                        return Err(ParseError::at(
674                            t.line,
675                            t.col,
676                            format!("expected '->' or end of chain, found {:?}", t.kind),
677                        ));
678                    }
679                    break;
680                }
681            }
682        }
683        Ok(())
684    }
685
686    /// Parse the full input and return a [`GraphDescription`].
687    fn parse(mut self) -> Result<GraphDescription, ParseError> {
688        let mut nodes: Vec<NodeSpec> = Vec::new();
689        let mut edges: Vec<EdgeSpec> = Vec::new();
690
691        // Skip leading separators.
692        self.skip_seps();
693
694        while self.peek().is_some() {
695            self.parse_chain(&mut nodes, &mut edges)?;
696            self.skip_seps();
697        }
698
699        Ok(GraphDescription { nodes, edges })
700    }
701}
702
703// ─────────────────────────────────────────────────────────────────────────────
704// Public API
705// ─────────────────────────────────────────────────────────────────────────────
706
707/// Parse a text-based graph DSL description into a [`GraphDescription`].
708///
709/// # Syntax
710///
711/// See the [module-level documentation][self] for a full description of the
712/// supported syntax.
713///
714/// # Errors
715///
716/// Returns [`GraphError::ConfigurationError`] (wrapping a [`ParseError`]) if
717/// the input is syntactically invalid.
718///
719/// # Example
720///
721/// ```
722/// use oximedia_graph::dsl::parse_graph_dsl;
723///
724/// let dsl = "source -> scale(1920,1080) -> h264_encoder -> mp4_sink";
725/// let desc = parse_graph_dsl(dsl).expect("parse should succeed");
726/// assert_eq!(desc.nodes.len(), 4);
727/// assert_eq!(desc.edges.len(), 3);
728/// ```
729pub fn parse_graph_dsl(input: &str) -> GraphResult<GraphDescription> {
730    let tokens = tokenise(input).map_err(GraphError::from)?;
731    let parser = Parser::new(tokens);
732    parser.parse().map_err(GraphError::from)
733}
734
735// ─────────────────────────────────────────────────────────────────────────────
736// Tests
737// ─────────────────────────────────────────────────────────────────────────────
738
739#[cfg(test)]
740mod tests {
741    use super::*;
742
743    // ── basic chain parsing ──────────────────────────────────────────────────
744
745    #[test]
746    fn test_parse_simple_chain() {
747        let dsl = "source -> scale(1920,1080) -> encoder -> sink";
748        let desc = parse_graph_dsl(dsl).expect("parse should succeed");
749        assert_eq!(desc.nodes.len(), 4);
750        assert_eq!(desc.edges.len(), 3);
751    }
752
753    #[test]
754    fn test_parse_single_node() {
755        let desc = parse_graph_dsl("source").expect("parse should succeed");
756        assert_eq!(desc.nodes.len(), 1);
757        assert_eq!(desc.edges.len(), 0);
758        assert_eq!(desc.nodes[0].filter, "source");
759    }
760
761    #[test]
762    fn test_parse_node_with_args() {
763        let desc = parse_graph_dsl("scale(1280,720)").expect("parse should succeed");
764        assert_eq!(desc.nodes[0].filter, "scale");
765        assert_eq!(desc.nodes[0].args, vec!["1280", "720"]);
766    }
767
768    #[test]
769    fn test_parse_explicit_label() {
770        let desc = parse_graph_dsl("my_src:source -> sink").expect("parse should succeed");
771        assert_eq!(desc.nodes[0].label, "my_src");
772        assert_eq!(desc.nodes[0].filter, "source");
773    }
774
775    // ── edge correctness ─────────────────────────────────────────────────────
776
777    #[test]
778    fn test_edges_connect_sequential_nodes() {
779        let desc = parse_graph_dsl("a -> b -> c").expect("parse should succeed");
780        assert_eq!(desc.edges.len(), 2);
781        assert_eq!(desc.edges[0].from, desc.nodes[0].label);
782        assert_eq!(desc.edges[0].to, desc.nodes[1].label);
783        assert_eq!(desc.edges[1].from, desc.nodes[1].label);
784        assert_eq!(desc.edges[1].to, desc.nodes[2].label);
785    }
786
787    // ── multi-chain (newline-separated) ─────────────────────────────────────
788
789    #[test]
790    fn test_parse_multiline_chains() {
791        let dsl = "src -> filter_a\nfilter_b -> sink";
792        let desc = parse_graph_dsl(dsl).expect("parse should succeed");
793        // 4 distinct nodes, 2 edges.
794        assert_eq!(desc.nodes.len(), 4);
795        assert_eq!(desc.edges.len(), 2);
796    }
797
798    #[test]
799    fn test_parse_semicolon_separated_chains() {
800        let dsl = "src -> enc; src2 -> enc2";
801        let desc = parse_graph_dsl(dsl).expect("parse should succeed");
802        assert_eq!(desc.nodes.len(), 4);
803        assert_eq!(desc.edges.len(), 2);
804    }
805
806    // ── shared node (fan-out) ────────────────────────────────────────────────
807
808    #[test]
809    fn test_shared_node_deduplication() {
810        let dsl = "tee:split\ntee -> branch_a\ntee -> branch_b";
811        let desc = parse_graph_dsl(dsl).expect("parse should succeed");
812        // "tee" should appear only once in nodes.
813        let tee_count = desc.nodes.iter().filter(|n| n.label == "tee").count();
814        assert_eq!(tee_count, 1, "shared node must be deduplicated");
815    }
816
817    // ── comments ────────────────────────────────────────────────────────────
818
819    #[test]
820    fn test_comments_are_ignored() {
821        let dsl = "# this is a comment\nsource -> sink\n# another comment";
822        let desc = parse_graph_dsl(dsl).expect("parse should succeed");
823        assert_eq!(desc.nodes.len(), 2);
824    }
825
826    // ── empty/whitespace input ───────────────────────────────────────────────
827
828    #[test]
829    fn test_empty_input() {
830        let desc = parse_graph_dsl("").expect("parse should succeed");
831        assert!(desc.is_empty());
832    }
833
834    #[test]
835    fn test_whitespace_only_input() {
836        let desc = parse_graph_dsl("   \n  \t  ").expect("parse should succeed");
837        assert!(desc.is_empty());
838    }
839
840    // ── quoted arguments ─────────────────────────────────────────────────────
841
842    #[test]
843    fn test_quoted_args_preserve_spaces() {
844        let desc =
845            parse_graph_dsl(r#"watermark("hello world",50,50)"#).expect("parse should succeed");
846        assert_eq!(desc.nodes[0].args[0], "hello world");
847    }
848
849    // ── GraphDescription helpers ─────────────────────────────────────────────
850
851    #[test]
852    fn test_contains_node() {
853        let desc = parse_graph_dsl("src -> sink").expect("parse should succeed");
854        // Auto-labels are src_0, sink_0.
855        assert!(desc.contains_node("src_0"));
856        assert!(!desc.contains_node("nonexistent"));
857    }
858
859    #[test]
860    fn test_node_lookup() {
861        let desc = parse_graph_dsl("my:scale(1920,1080)").expect("parse should succeed");
862        let node = desc.node("my").expect("node should exist");
863        assert_eq!(node.filter, "scale");
864        assert_eq!(node.args, vec!["1920", "1080"]);
865    }
866
867    // ── display ──────────────────────────────────────────────────────────────
868
869    #[test]
870    fn test_node_spec_display_no_args() {
871        let n = NodeSpec::new("my_src", "source");
872        assert_eq!(n.to_string(), "my_src:source");
873    }
874
875    #[test]
876    fn test_node_spec_display_with_args() {
877        let n = NodeSpec::with_args("s0", "scale", vec!["1920".into(), "1080".into()]);
878        assert_eq!(n.to_string(), "s0:scale(1920, 1080)");
879    }
880
881    #[test]
882    fn test_edge_spec_display() {
883        let e = EdgeSpec::new("src", "sink");
884        assert_eq!(e.to_string(), "src -> sink");
885    }
886
887    // ── error handling ───────────────────────────────────────────────────────
888
889    #[test]
890    fn test_unterminated_string_returns_error() {
891        let result = parse_graph_dsl(r#"node("unterminated)"#);
892        assert!(result.is_err());
893    }
894
895    #[test]
896    fn test_unexpected_char_returns_error() {
897        let result = parse_graph_dsl("node @ other");
898        assert!(result.is_err());
899    }
900
901    #[test]
902    fn test_bare_arrow_returns_error() {
903        let result = parse_graph_dsl("-> sink");
904        assert!(result.is_err());
905    }
906
907    // ── complex pipeline ─────────────────────────────────────────────────────
908
909    #[test]
910    fn test_complex_pipeline() {
911        let dsl = r#"
912            # Full transcode pipeline
913            input:source -> deinterlace -> normalize(loudness,-14)
914            input -> scale(1920,1080) -> h264:encoder(crf,23) -> mux:mp4_sink
915        "#;
916        let desc = parse_graph_dsl(dsl).expect("parse should succeed");
917        // Nodes: input, deinterlace_0, normalize_0, scale_0, h264, mux
918        assert!(desc.nodes.len() >= 4);
919        assert!(desc.edges.len() >= 3);
920        // input shared across two chains.
921        let input_count = desc.nodes.iter().filter(|n| n.label == "input").count();
922        assert_eq!(input_count, 1, "shared 'input' node must be deduplicated");
923    }
924
925    #[test]
926    fn test_auto_label_uniqueness() {
927        // Two anonymous scale nodes should get different auto-labels.
928        let dsl = "src -> scale(1920,1080)\nsrc2 -> scale(640,360)";
929        let desc = parse_graph_dsl(dsl).expect("parse should succeed");
930        let scale_labels: Vec<&str> = desc
931            .nodes
932            .iter()
933            .filter(|n| n.filter == "scale")
934            .map(|n| n.label.as_str())
935            .collect();
936        assert_eq!(scale_labels.len(), 2);
937        assert_ne!(
938            scale_labels[0], scale_labels[1],
939            "auto-labels must be unique"
940        );
941    }
942}