Skip to main content

rfc/models/
document.rs

1use serde::{Deserialize, Serialize};
2use urlencoding::encode;
3
4use crate::api::DATATRACKER_BASE_URL;
5
6/// The type of document - either an RFC or an Internet-Draft
7#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
8pub enum DocumentType {
9    /// An RFC document with its number
10    Rfc(u32),
11    /// An Internet-Draft with its name
12    Draft(String),
13}
14
15impl DocumentType {
16    /// Parse a user-supplied identifier into a `DocumentType`.
17    ///
18    /// Recognized RFC forms (case-insensitive, leading/trailing whitespace
19    /// trimmed): bare numbers like `9000`, prefixed forms like `rfc9000`
20    /// or `RFC 9000`. Anything else is treated as a draft name; a missing
21    /// `draft-` prefix is added automatically so users can write either
22    /// `rfc 4271`-style shorthand or full draft names.
23    pub fn from_user_input(s: &str) -> Self {
24        let s = s.trim().to_lowercase();
25
26        if let Some(num_str) = s.strip_prefix("rfc") {
27            if let Ok(num) = num_str.trim().parse::<u32>() {
28                return DocumentType::Rfc(num);
29            }
30        }
31
32        if let Ok(num) = s.parse::<u32>() {
33            return DocumentType::Rfc(num);
34        }
35
36        if s.starts_with("draft-") {
37            DocumentType::Draft(s)
38        } else {
39            DocumentType::Draft(format!("draft-{}", s))
40        }
41    }
42
43    /// Parse a canonical, server-supplied document name (e.g. `rfc9000`,
44    /// `draft-ietf-quic-transport-34`). Unlike `from_user_input`, this
45    /// makes no attempt to repair malformed input — the only ambiguity is
46    /// the RFC-vs-draft prefix.
47    pub fn from_canonical_name(name: &str) -> Self {
48        if let Some(num_str) = name.strip_prefix("rfc") {
49            if let Ok(num) = num_str.parse::<u32>() {
50                return DocumentType::Rfc(num);
51            }
52        }
53        DocumentType::Draft(name.to_string())
54    }
55
56    /// Get the canonical name for this document
57    pub fn name(&self) -> String {
58        match self {
59            DocumentType::Rfc(num) => format!("rfc{}", num),
60            DocumentType::Draft(name) => name.clone(),
61        }
62    }
63
64    /// Get a display-friendly name
65    pub fn display_name(&self) -> String {
66        match self {
67            DocumentType::Rfc(num) => format!("RFC {}", num),
68            DocumentType::Draft(name) => name.clone(),
69        }
70    }
71
72    /// Get the IETF Datatracker URL for this document
73    pub fn datatracker_url(&self) -> String {
74        match self {
75            DocumentType::Rfc(num) => format!("{}/doc/rfc{}/", DATATRACKER_BASE_URL, num),
76            DocumentType::Draft(name) => {
77                format!("{}/doc/{}/", DATATRACKER_BASE_URL, encode(name))
78            }
79        }
80    }
81}
82
83impl std::fmt::Display for DocumentType {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        write!(f, "{}", self.display_name())
86    }
87}
88
89/// Document content format
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91pub enum Format {
92    Html,
93    Text,
94}
95
96impl Format {
97    pub fn extension(&self) -> &'static str {
98        match self {
99            Format::Html => "html",
100            Format::Text => "txt",
101        }
102    }
103}
104
105/// An IETF document (RFC or Internet-Draft).
106///
107/// Only the fields the CLI actually displays are kept; richer metadata
108/// (pages, authors, publication date, etc.) lives on the wire type and
109/// is dropped at the API boundary.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Document {
112    /// Canonical name (e.g. `rfc9000` or `draft-ietf-quic-transport-34`).
113    pub name: String,
114    /// Human-readable title.
115    pub title: String,
116    pub doc_type: DocumentType,
117}
118
119impl Document {
120    pub fn new(name: String, title: String, doc_type: DocumentType) -> Self {
121        Self {
122            name,
123            title,
124            doc_type,
125        }
126    }
127
128    /// Get a short display title (truncated if necessary)
129    pub fn short_title(&self, max_len: usize) -> String {
130        if self.title.chars().count() <= max_len {
131            self.title.clone()
132        } else {
133            let truncated: String = self.title.chars().take(max_len.saturating_sub(3)).collect();
134            format!("{}...", truncated)
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_from_user_input_rfc() {
145        assert_eq!(
146            DocumentType::from_user_input("9000"),
147            DocumentType::Rfc(9000)
148        );
149        assert_eq!(
150            DocumentType::from_user_input("rfc9000"),
151            DocumentType::Rfc(9000)
152        );
153        assert_eq!(
154            DocumentType::from_user_input("RFC9000"),
155            DocumentType::Rfc(9000)
156        );
157        assert_eq!(
158            DocumentType::from_user_input("RFC 9000"),
159            DocumentType::Rfc(9000)
160        );
161        assert_eq!(
162            DocumentType::from_user_input("  rfc9000  "),
163            DocumentType::Rfc(9000)
164        );
165    }
166
167    #[test]
168    fn test_from_user_input_draft() {
169        assert_eq!(
170            DocumentType::from_user_input("draft-ietf-quic-transport-34"),
171            DocumentType::Draft("draft-ietf-quic-transport-34".to_string())
172        );
173        // Bare names get a draft- prefix added.
174        assert_eq!(
175            DocumentType::from_user_input("ietf-quic-transport"),
176            DocumentType::Draft("draft-ietf-quic-transport".to_string())
177        );
178    }
179
180    #[test]
181    fn test_from_canonical_name() {
182        assert_eq!(
183            DocumentType::from_canonical_name("rfc9000"),
184            DocumentType::Rfc(9000)
185        );
186        assert_eq!(
187            DocumentType::from_canonical_name("draft-ietf-quic-transport-34"),
188            DocumentType::Draft("draft-ietf-quic-transport-34".to_string())
189        );
190        // "rfc" with no number is a draft name on the wire (rare but legal).
191        assert_eq!(
192            DocumentType::from_canonical_name("rfcfoo"),
193            DocumentType::Draft("rfcfoo".to_string())
194        );
195    }
196
197    #[test]
198    fn test_document_type_display() {
199        assert_eq!(DocumentType::Rfc(9000).to_string(), "RFC 9000");
200        assert_eq!(
201            DocumentType::Draft("draft-ietf-quic-transport".to_string()).to_string(),
202            "draft-ietf-quic-transport"
203        );
204    }
205
206    #[test]
207    fn test_datatracker_url() {
208        assert_eq!(
209            DocumentType::Rfc(9000).datatracker_url(),
210            "https://datatracker.ietf.org/doc/rfc9000/"
211        );
212        assert_eq!(
213            DocumentType::Draft("draft-ietf-quic-transport".to_string()).datatracker_url(),
214            "https://datatracker.ietf.org/doc/draft-ietf-quic-transport/"
215        );
216        assert_eq!(
217            DocumentType::Draft("draft with spaces/and/slashes".to_string()).datatracker_url(),
218            "https://datatracker.ietf.org/doc/draft%20with%20spaces%2Fand%2Fslashes/"
219        );
220    }
221
222    #[test]
223    fn test_short_title() {
224        let doc = Document::new(
225            "rfc9000".to_string(),
226            "A Very Long Title That Needs Truncation".to_string(),
227            DocumentType::Rfc(9000),
228        );
229
230        // No truncation needed
231        assert_eq!(
232            doc.short_title(100),
233            "A Very Long Title That Needs Truncation"
234        );
235
236        // Truncation with ellipsis
237        assert_eq!(doc.short_title(20), "A Very Long Title...");
238
239        // Edge cases
240        assert_eq!(doc.short_title(3), "...");
241        assert_eq!(doc.short_title(0), "...");
242    }
243
244    #[test]
245    fn test_short_title_utf8() {
246        // Test with multibyte UTF-8 characters to ensure no panic
247        let doc = Document::new(
248            "rfc1234".to_string(),
249            "Café résumé naïve".to_string(),
250            DocumentType::Rfc(1234),
251        );
252
253        // Should not panic on multibyte characters
254        let result = doc.short_title(10);
255        assert!(result.ends_with("..."));
256        assert!(result.chars().count() <= 10);
257    }
258}