1use std::collections::BTreeMap;
4use std::fmt::{Display, Formatter};
5
6use crate::{IndexUrl, UrlError};
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
10pub struct AdapterId(String);
11
12impl AdapterId {
13 #[must_use]
15 pub fn new(input: impl Into<String>) -> Self {
16 Self(input.into())
17 }
18
19 #[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
34pub struct IndexDocument {
35 pub title: String,
37 pub nodes: Vec<IndexNode>,
39 pub metadata: Metadata,
41}
42
43impl IndexDocument {
44 #[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 pub fn push(&mut self, node: IndexNode) {
56 self.nodes.push(node);
57 }
58
59 #[must_use]
61 pub fn is_empty(&self) -> bool {
62 self.nodes.iter().all(IndexNode::is_layout_only)
63 }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Default)]
68pub struct Metadata {
69 pub canonical_url: Option<String>,
71 pub author: Option<String>,
73 pub language: Option<String>,
75 pub description: Option<String>,
77 pub open_graph_title: Option<String>,
79 pub open_graph_description: Option<String>,
81 pub adapter_id: Option<AdapterId>,
83 pub quality: Option<DocumentQuality>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
89pub enum DocumentQualityCategory {
90 Adapter,
92 StrongGeneric,
94 PartialGeneric,
96 Fallback,
98 Failed,
100}
101
102impl DocumentQualityCategory {
103 #[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#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct DocumentQuality {
125 pub category: DocumentQualityCategory,
127 pub score: u8,
129 pub reasons: Vec<String>,
131}
132
133impl DocumentQuality {
134 #[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#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum IndexNode {
152 Heading {
154 level: u8,
156 text: String,
158 },
159 Paragraph(String),
161 Link(Link),
163 List {
165 ordered: bool,
167 items: Vec<String>,
169 },
170 CodeBlock {
172 language: Option<String>,
174 code: String,
176 },
177 Table {
179 rows: Vec<Vec<String>>,
181 },
182 Spacer {
184 lines: u8,
186 },
187 Section {
189 role: SectionRole,
191 title: Option<String>,
193 collapsed: bool,
195 nodes: Vec<IndexNode>,
197 },
198 Image {
200 alt: String,
202 src: Option<String>,
204 },
205 Form(Form),
207 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum SectionRole {
227 Main,
229 Navigation,
231 Aside,
233 Footer,
235 Comments,
237 Related,
239 Unknown,
241}
242
243impl SectionRole {
244 #[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#[derive(Debug, Clone, PartialEq, Eq)]
267pub struct Link {
268 pub text: String,
270 pub href: String,
272}
273
274impl Link {
275 #[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#[derive(Debug, Clone, PartialEq, Eq)]
287pub struct Form {
288 pub name: String,
290 pub method: String,
292 pub action: String,
294 pub inputs: Vec<Input>,
296 pub buttons: Vec<ButtonAction>,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq)]
302pub struct Input {
303 pub name: String,
305 pub kind: String,
307 pub value: Option<String>,
309 pub required: bool,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
315pub struct ButtonAction {
316 pub name: Option<String>,
318 pub value: Option<String>,
320 pub label: String,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
326pub enum FormMethod {
327 Get,
329 Post,
331}
332
333impl FormMethod {
334 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum ValidationState {
356 Valid,
358 MissingRequiredField(String),
360}
361
362#[derive(Debug, Clone, PartialEq, Eq)]
364pub struct FormSubmission {
365 pub method: FormMethod,
367 pub action: IndexUrl,
369 pub body: Option<String>,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
375pub enum FormSubmitError {
376 MissingRequiredField(String),
378 RelativeActionWithoutBase(String),
380 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 #[must_use]
401 pub fn form_method(&self) -> FormMethod {
402 FormMethod::parse(&self.method)
403 }
404
405 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 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}