Skip to main content

index_core/
component.rs

1//! Terminal-native document components.
2
3use std::collections::BTreeMap;
4use std::fmt::{Display, Formatter};
5
6use crate::{IndexUrl, UrlError};
7
8/// Stable identifier for a site adapter.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct AdapterId(String);
11
12impl AdapterId {
13    /// Creates an adapter identifier.
14    #[must_use]
15    pub fn new(input: impl Into<String>) -> Self {
16        Self(input.into())
17    }
18
19    /// Returns the adapter identifier string.
20    #[must_use]
21    pub fn as_str(&self) -> &str {
22        &self.0
23    }
24}
25
26impl Display for AdapterId {
27    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
28        f.write_str(self.as_str())
29    }
30}
31
32/// A semantic document emitted by the transformer and consumed by renderers.
33#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub struct IndexDocument {
35    /// Document title.
36    pub title: String,
37    /// Ordered semantic nodes.
38    pub nodes: Vec<IndexNode>,
39    /// Optional page metadata.
40    pub metadata: Metadata,
41}
42
43impl IndexDocument {
44    /// Creates a document with a title.
45    #[must_use]
46    pub fn titled(title: impl Into<String>) -> Self {
47        Self {
48            title: title.into(),
49            nodes: Vec::new(),
50            metadata: Metadata::default(),
51        }
52    }
53
54    /// Adds a node to the document.
55    pub fn push(&mut self, node: IndexNode) {
56        self.nodes.push(node);
57    }
58
59    /// Returns true when the document has no user-visible nodes.
60    #[must_use]
61    pub fn is_empty(&self) -> bool {
62        self.nodes.iter().all(IndexNode::is_layout_only)
63    }
64}
65
66/// Optional document metadata.
67#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct Metadata {
69    /// Canonical URL when known.
70    pub canonical_url: Option<String>,
71    /// Author when known.
72    pub author: Option<String>,
73    /// Declared document language when known.
74    pub language: Option<String>,
75    /// Description when known.
76    pub description: Option<String>,
77    /// OpenGraph title when known.
78    pub open_graph_title: Option<String>,
79    /// OpenGraph description when known.
80    pub open_graph_description: Option<String>,
81    /// Adapter that produced this document when known.
82    pub adapter_id: Option<AdapterId>,
83    /// Transform quality assessment when known.
84    pub quality: Option<DocumentQuality>,
85}
86
87/// Stable transform quality category.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
89pub enum DocumentQualityCategory {
90    /// A fixture-backed site adapter produced the document.
91    Adapter,
92    /// The generic static transformer emitted a strong semantic view.
93    StrongGeneric,
94    /// The generic static transformer emitted sparse or partial content.
95    PartialGeneric,
96    /// A fallback path produced a deterministic document.
97    Fallback,
98    /// Transformation or retrieval failed closed.
99    Failed,
100}
101
102impl DocumentQualityCategory {
103    /// Returns the stable serialized category name.
104    #[must_use]
105    pub const fn as_str(self) -> &'static str {
106        match self {
107            Self::Adapter => "adapter",
108            Self::StrongGeneric => "strong-generic",
109            Self::PartialGeneric => "partial-generic",
110            Self::Fallback => "fallback",
111            Self::Failed => "failed",
112        }
113    }
114}
115
116impl Display for DocumentQualityCategory {
117    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
118        f.write_str(self.as_str())
119    }
120}
121
122/// Deterministic quality metadata for a transformed document.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct DocumentQuality {
125    /// Stable quality category.
126    pub category: DocumentQualityCategory,
127    /// Bounded score from 0 to 100.
128    pub score: u8,
129    /// Human-readable deterministic reasons for the score.
130    pub reasons: Vec<String>,
131}
132
133impl DocumentQuality {
134    /// Creates a quality value with score clamped to `0..=100`.
135    #[must_use]
136    pub fn new(
137        category: DocumentQualityCategory,
138        score: u8,
139        reasons: impl IntoIterator<Item = impl Into<String>>,
140    ) -> Self {
141        Self {
142            category,
143            score: score.min(100),
144            reasons: reasons.into_iter().map(Into::into).collect(),
145        }
146    }
147}
148
149/// A semantic terminal-native node.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum IndexNode {
152    /// Heading with one-based level.
153    Heading {
154        /// One-based heading level.
155        level: u8,
156        /// Heading text content.
157        text: String,
158    },
159    /// Paragraph text.
160    Paragraph(String),
161    /// Link with stable display address.
162    Link(Link),
163    /// Ordered or unordered list.
164    List {
165        /// Whether numbering is semantic (`true`) or bullet-style (`false`).
166        ordered: bool,
167        /// Ordered display items.
168        items: Vec<String>,
169    },
170    /// Code block.
171    CodeBlock {
172        /// Optional declared language identifier.
173        language: Option<String>,
174        /// Code content.
175        code: String,
176    },
177    /// Table represented as rows of cells.
178    Table {
179        /// Rows of cells in display order.
180        rows: Vec<Vec<String>>,
181    },
182    /// Vertical rhythm hint derived from semantic block boundaries or bounded CSS spacing.
183    Spacer {
184        /// Extra blank terminal lines to preserve, clamped by producers.
185        lines: u8,
186    },
187    /// Semantic page region, usually collapsed when it is secondary to the main content.
188    Section {
189        /// Region role inferred from HTML landmarks or common page conventions.
190        role: SectionRole,
191        /// Optional region title.
192        title: Option<String>,
193        /// Whether renderers should initially summarize rather than expand the region.
194        collapsed: bool,
195        /// Region contents.
196        nodes: Vec<IndexNode>,
197    },
198    /// Image proxy. The renderer decides how to display it.
199    Image {
200        /// Image alternate text.
201        alt: String,
202        /// Optional source URL.
203        src: Option<String>,
204    },
205    /// Web form represented as terminal action fields.
206    Form(Form),
207    /// Recoverable error displayed to the user.
208    Error(String),
209}
210
211impl IndexNode {
212    fn is_layout_only(&self) -> bool {
213        match self {
214            Self::Spacer { .. } => true,
215            Self::Section { title, nodes, .. } => {
216                title.as_deref().unwrap_or_default().trim().is_empty()
217                    && nodes.iter().all(Self::is_layout_only)
218            }
219            _ => false,
220        }
221    }
222}
223
224/// Semantic page region role.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum SectionRole {
227    /// Primary content region.
228    Main,
229    /// Navigation region.
230    Navigation,
231    /// Sidebar or complementary content.
232    Aside,
233    /// Footer or content information.
234    Footer,
235    /// Comments or discussion region.
236    Comments,
237    /// Related links or related content.
238    Related,
239    /// Unknown secondary region.
240    Unknown,
241}
242
243impl SectionRole {
244    /// Returns a stable lowercase role name.
245    #[must_use]
246    pub const fn as_str(self) -> &'static str {
247        match self {
248            Self::Main => "main",
249            Self::Navigation => "navigation",
250            Self::Aside => "aside",
251            Self::Footer => "footer",
252            Self::Comments => "comments",
253            Self::Related => "related",
254            Self::Unknown => "section",
255        }
256    }
257}
258
259impl Display for SectionRole {
260    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
261        f.write_str(self.as_str())
262    }
263}
264
265/// A link with stable text and target.
266#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct Link {
268    /// Human-readable label.
269    pub text: String,
270    /// Link target.
271    pub href: String,
272}
273
274impl Link {
275    /// Creates a new link.
276    #[must_use]
277    pub fn new(text: impl Into<String>, href: impl Into<String>) -> Self {
278        Self {
279            text: text.into(),
280            href: href.into(),
281        }
282    }
283}
284
285/// A terminal-compatible form.
286#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Form {
288    /// Form name or inferred description.
289    pub name: String,
290    /// Method such as GET or POST.
291    pub method: String,
292    /// Action target.
293    pub action: String,
294    /// Form inputs.
295    pub inputs: Vec<Input>,
296    /// Button actions associated with this form.
297    pub buttons: Vec<ButtonAction>,
298}
299
300/// A terminal-compatible form input.
301#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct Input {
303    /// Input name.
304    pub name: String,
305    /// Input kind.
306    pub kind: String,
307    /// Optional current value.
308    pub value: Option<String>,
309    /// Whether a value is required before submission.
310    pub required: bool,
311}
312
313/// A terminal-compatible form button.
314#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct ButtonAction {
316    /// Optional button name submitted with the form.
317    pub name: Option<String>,
318    /// Optional button value submitted with the form.
319    pub value: Option<String>,
320    /// Human-readable label.
321    pub label: String,
322}
323
324/// Supported form submission methods.
325#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum FormMethod {
327    /// HTTP GET-style query submission.
328    Get,
329    /// HTTP POST-style body submission.
330    Post,
331}
332
333impl FormMethod {
334    /// Parses a form method, defaulting empty values to GET.
335    #[must_use]
336    pub fn parse(input: &str) -> Self {
337        match input.trim().to_ascii_uppercase().as_str() {
338            "POST" => Self::Post,
339            _ => Self::Get,
340        }
341    }
342
343    /// Returns the method as an uppercase string.
344    #[must_use]
345    pub const fn as_str(&self) -> &'static str {
346        match self {
347            Self::Get => "GET",
348            Self::Post => "POST",
349        }
350    }
351}
352
353/// Validation state for form submission.
354#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum ValidationState {
356    /// Form values are valid enough to submit.
357    Valid,
358    /// A required field has no value.
359    MissingRequiredField(String),
360}
361
362/// A resolved form submission request.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub struct FormSubmission {
365    /// Submission method.
366    pub method: FormMethod,
367    /// Resolved action URL.
368    pub action: IndexUrl,
369    /// Encoded request body for POST submissions.
370    pub body: Option<String>,
371}
372
373/// Form submission errors.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub enum FormSubmitError {
376    /// A required field has no value.
377    MissingRequiredField(String),
378    /// A relative action was submitted without a base URL.
379    RelativeActionWithoutBase(String),
380    /// The action URL is invalid.
381    InvalidAction(UrlError),
382}
383
384impl Display for FormSubmitError {
385    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
386        match self {
387            Self::MissingRequiredField(name) => write!(f, "required form field is missing: {name}"),
388            Self::RelativeActionWithoutBase(action) => {
389                write!(f, "form action requires a base URL: {action}")
390            }
391            Self::InvalidAction(error) => write!(f, "form action is invalid: {error}"),
392        }
393    }
394}
395
396impl std::error::Error for FormSubmitError {}
397
398impl Form {
399    /// Returns the parsed submission method.
400    #[must_use]
401    pub fn form_method(&self) -> FormMethod {
402        FormMethod::parse(&self.method)
403    }
404
405    /// Validates and resolves a submission request.
406    pub fn submit(
407        &self,
408        base_url: Option<&IndexUrl>,
409        values: &[(&str, &str)],
410    ) -> Result<FormSubmission, FormSubmitError> {
411        let fields = self.submission_fields(values)?;
412        let method = self.form_method();
413        let action = resolve_action(&self.action, base_url)?;
414
415        match method {
416            FormMethod::Get => {
417                let mut url = ::url::Url::parse(action.as_str()).map_err(|error| {
418                    FormSubmitError::InvalidAction(UrlError::Invalid(error.to_string()))
419                })?;
420                {
421                    let mut pairs = url.query_pairs_mut();
422                    for (name, value) in &fields {
423                        pairs.append_pair(name, value);
424                    }
425                }
426                Ok(FormSubmission {
427                    method,
428                    action: IndexUrl::parse(url.as_str())
429                        .map_err(FormSubmitError::InvalidAction)?,
430                    body: None,
431                })
432            }
433            FormMethod::Post => {
434                let mut serializer = ::url::form_urlencoded::Serializer::new(String::new());
435                for (name, value) in &fields {
436                    serializer.append_pair(name, value);
437                }
438                Ok(FormSubmission {
439                    method,
440                    action,
441                    body: Some(serializer.finish()),
442                })
443            }
444        }
445    }
446
447    /// Returns validation state for a set of field overrides.
448    pub fn validate(&self, values: &[(&str, &str)]) -> ValidationState {
449        match self.submission_fields(values) {
450            Ok(_fields) => ValidationState::Valid,
451            Err(FormSubmitError::MissingRequiredField(name)) => {
452                ValidationState::MissingRequiredField(name)
453            }
454            Err(_) => ValidationState::Valid,
455        }
456    }
457
458    fn submission_fields(
459        &self,
460        values: &[(&str, &str)],
461    ) -> Result<Vec<(String, String)>, FormSubmitError> {
462        let overrides = values
463            .iter()
464            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
465            .collect::<BTreeMap<_, _>>();
466        let mut fields = Vec::new();
467
468        for input in &self.inputs {
469            if input.name.is_empty() || is_button_like(&input.kind) {
470                continue;
471            }
472
473            let value = overrides
474                .get(&input.name)
475                .cloned()
476                .or_else(|| input.value.clone())
477                .unwrap_or_default();
478            if input.required && value.is_empty() {
479                return Err(FormSubmitError::MissingRequiredField(input.name.clone()));
480            }
481            fields.push((input.name.clone(), value));
482        }
483
484        for (name, value) in overrides {
485            if !fields.iter().any(|(field_name, _)| field_name == &name) {
486                fields.push((name, value));
487            }
488        }
489
490        Ok(fields)
491    }
492}
493
494fn resolve_action(action: &str, base_url: Option<&IndexUrl>) -> Result<IndexUrl, FormSubmitError> {
495    if let Ok(url) = IndexUrl::parse(action) {
496        return Ok(url);
497    }
498
499    let Some(base_url) = base_url else {
500        return Err(FormSubmitError::RelativeActionWithoutBase(
501            action.to_owned(),
502        ));
503    };
504    let base = ::url::Url::parse(base_url.as_str())
505        .map_err(|error| FormSubmitError::InvalidAction(UrlError::Invalid(error.to_string())))?;
506    let joined = base
507        .join(action)
508        .map_err(|error| FormSubmitError::InvalidAction(UrlError::Invalid(error.to_string())))?;
509    IndexUrl::parse(joined.as_str()).map_err(FormSubmitError::InvalidAction)
510}
511
512fn is_button_like(kind: &str) -> bool {
513    matches!(
514        kind.trim().to_ascii_lowercase().as_str(),
515        "button" | "submit" | "reset" | "image"
516    )
517}
518
519#[cfg(test)]
520mod tests {
521    use super::{
522        AdapterId, DocumentQuality, DocumentQualityCategory, Form, FormMethod, FormSubmitError,
523        IndexDocument, IndexNode, Input, Link, SectionRole, ValidationState,
524    };
525    use crate::IndexUrl;
526
527    #[test]
528    fn document_starts_empty() {
529        let doc = IndexDocument::titled("Example");
530        assert_eq!(doc.title, "Example");
531        assert!(doc.is_empty());
532    }
533
534    #[test]
535    fn document_accepts_nodes() {
536        let mut doc = IndexDocument::titled("Example");
537        doc.push(IndexNode::Paragraph("Hello".to_owned()));
538        assert!(!doc.is_empty());
539    }
540
541    #[test]
542    fn document_with_only_layout_spacers_is_empty() {
543        let mut doc = IndexDocument::titled("Example");
544        doc.push(IndexNode::Spacer { lines: 2 });
545        assert!(doc.is_empty());
546    }
547
548    #[test]
549    fn document_with_only_empty_section_is_empty() {
550        let mut doc = IndexDocument::titled("Example");
551        doc.push(IndexNode::Section {
552            role: SectionRole::Aside,
553            title: None,
554            collapsed: true,
555            nodes: vec![IndexNode::Spacer { lines: 1 }],
556        });
557        assert!(doc.is_empty());
558    }
559
560    #[test]
561    fn section_role_names_are_stable() {
562        let roles = [
563            (SectionRole::Main, "main"),
564            (SectionRole::Navigation, "navigation"),
565            (SectionRole::Aside, "aside"),
566            (SectionRole::Footer, "footer"),
567            (SectionRole::Comments, "comments"),
568            (SectionRole::Related, "related"),
569            (SectionRole::Unknown, "section"),
570        ];
571
572        for (role, label) in roles {
573            assert_eq!(role.as_str(), label);
574            assert_eq!(role.to_string(), label);
575        }
576    }
577
578    #[test]
579    fn link_constructor_preserves_text_and_href() {
580        let link = Link::new("Docs", "https://example.com/docs");
581        assert_eq!(link.text, "Docs");
582        assert_eq!(link.href, "https://example.com/docs");
583    }
584
585    #[test]
586    fn adapter_id_displays_stable_value() {
587        let id = AdapterId::new("github.repository");
588        assert_eq!(id.as_str(), "github.repository");
589        assert_eq!(id.to_string(), "github.repository");
590    }
591
592    #[test]
593    fn document_quality_category_names_are_stable() {
594        let categories = [
595            (DocumentQualityCategory::Adapter, "adapter"),
596            (DocumentQualityCategory::StrongGeneric, "strong-generic"),
597            (DocumentQualityCategory::PartialGeneric, "partial-generic"),
598            (DocumentQualityCategory::Fallback, "fallback"),
599            (DocumentQualityCategory::Failed, "failed"),
600        ];
601
602        for (category, name) in categories {
603            assert_eq!(category.as_str(), name);
604            assert_eq!(category.to_string(), name);
605        }
606    }
607
608    #[test]
609    fn document_quality_clamps_score() {
610        let quality = DocumentQuality::new(
611            DocumentQualityCategory::StrongGeneric,
612            250,
613            ["readable body"],
614        );
615
616        assert_eq!(quality.score, 100);
617        assert_eq!(quality.reasons, vec!["readable body".to_owned()]);
618    }
619
620    #[test]
621    fn get_form_submission_resolves_query_url() -> Result<(), Box<dyn std::error::Error>> {
622        let form = Form {
623            name: "search".to_owned(),
624            method: "GET".to_owned(),
625            action: "/search".to_owned(),
626            inputs: vec![Input {
627                name: "q".to_owned(),
628                kind: "search".to_owned(),
629                value: None,
630                required: true,
631            }],
632            buttons: Vec::new(),
633        };
634        let base = IndexUrl::parse("https://example.com/docs/")?;
635        let submission = form.submit(Some(&base), &[("q", "index browser")])?;
636
637        assert_eq!(submission.method, FormMethod::Get);
638        assert_eq!(
639            submission.action.as_str(),
640            "https://example.com/search?q=index+browser"
641        );
642        assert_eq!(submission.body, None);
643        Ok(())
644    }
645
646    #[test]
647    fn post_form_submission_uses_encoded_body() -> Result<(), Box<dyn std::error::Error>> {
648        let form = Form {
649            name: "login".to_owned(),
650            method: "POST".to_owned(),
651            action: "https://example.com/login".to_owned(),
652            inputs: vec![Input {
653                name: "token".to_owned(),
654                kind: "hidden".to_owned(),
655                value: Some("abc".to_owned()),
656                required: false,
657            }],
658            buttons: Vec::new(),
659        };
660        let submission = form.submit(None, &[("user", "ada")])?;
661
662        assert_eq!(submission.method, FormMethod::Post);
663        assert_eq!(submission.action.as_str(), "https://example.com/login");
664        assert_eq!(submission.body.as_deref(), Some("token=abc&user=ada"));
665        Ok(())
666    }
667
668    #[test]
669    fn form_submission_uses_default_field_values_and_allows_overrides()
670    -> Result<(), Box<dyn std::error::Error>> {
671        let form = Form {
672            name: "filters".to_owned(),
673            method: "GET".to_owned(),
674            action: "https://example.com/search".to_owned(),
675            inputs: vec![
676                Input {
677                    name: "q".to_owned(),
678                    kind: "search".to_owned(),
679                    value: None,
680                    required: true,
681                },
682                Input {
683                    name: "sort".to_owned(),
684                    kind: "select".to_owned(),
685                    value: Some("recent".to_owned()),
686                    required: false,
687                },
688            ],
689            buttons: Vec::new(),
690        };
691
692        let submission = form.submit(None, &[("q", "index"), ("sort", "relevance")])?;
693        assert_eq!(
694            submission.action.as_str(),
695            "https://example.com/search?q=index&sort=relevance"
696        );
697
698        let defaulted = form.submit(None, &[("q", "index")])?;
699        assert_eq!(
700            defaulted.action.as_str(),
701            "https://example.com/search?q=index&sort=recent"
702        );
703        Ok(())
704    }
705
706    #[test]
707    fn form_submission_reports_missing_required_field() {
708        let form = Form {
709            name: "search".to_owned(),
710            method: "GET".to_owned(),
711            action: "https://example.com/search".to_owned(),
712            inputs: vec![Input {
713                name: "q".to_owned(),
714                kind: "search".to_owned(),
715                value: None,
716                required: true,
717            }],
718            buttons: Vec::new(),
719        };
720
721        assert_eq!(
722            form.validate(&[]),
723            ValidationState::MissingRequiredField("q".to_owned())
724        );
725        assert_eq!(
726            form.submit(None, &[]),
727            Err(FormSubmitError::MissingRequiredField("q".to_owned()))
728        );
729    }
730
731    #[test]
732    fn relative_action_without_base_is_diagnostic() {
733        let form = Form {
734            name: "search".to_owned(),
735            method: "GET".to_owned(),
736            action: "/search".to_owned(),
737            inputs: Vec::new(),
738            buttons: Vec::new(),
739        };
740
741        assert_eq!(
742            form.submit(None, &[]),
743            Err(FormSubmitError::RelativeActionWithoutBase(
744                "/search".to_owned()
745            ))
746        );
747    }
748}