Skip to main content

memo_cli/preprocess/
mod.rs

1mod detect;
2mod validate;
3
4use serde::{Deserialize, Serialize};
5
6pub use detect::detect_content_type;
7pub use validate::validate_content;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum ContentType {
12    Url,
13    Json,
14    Yaml,
15    Xml,
16    Markdown,
17    Text,
18    Unknown,
19}
20
21impl ContentType {
22    pub const fn as_str(self) -> &'static str {
23        match self {
24            Self::Url => "url",
25            Self::Json => "json",
26            Self::Yaml => "yaml",
27            Self::Xml => "xml",
28            Self::Markdown => "markdown",
29            Self::Text => "text",
30            Self::Unknown => "unknown",
31        }
32    }
33
34    pub fn parse(value: &str) -> Option<Self> {
35        match value {
36            "url" => Some(Self::Url),
37            "json" => Some(Self::Json),
38            "yaml" => Some(Self::Yaml),
39            "xml" => Some(Self::Xml),
40            "markdown" => Some(Self::Markdown),
41            "text" => Some(Self::Text),
42            "unknown" => Some(Self::Unknown),
43            _ => None,
44        }
45    }
46}
47
48impl std::fmt::Display for ContentType {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum ValidationStatus {
57    Valid,
58    Invalid,
59    Unknown,
60    Skipped,
61}
62
63impl ValidationStatus {
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::Valid => "valid",
67            Self::Invalid => "invalid",
68            Self::Unknown => "unknown",
69            Self::Skipped => "skipped",
70        }
71    }
72
73    pub fn parse(value: &str) -> Option<Self> {
74        match value {
75            "valid" => Some(Self::Valid),
76            "invalid" => Some(Self::Invalid),
77            "unknown" => Some(Self::Unknown),
78            "skipped" => Some(Self::Skipped),
79            _ => None,
80        }
81    }
82}
83
84impl std::fmt::Display for ValidationStatus {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.write_str(self.as_str())
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91pub struct ValidationError {
92    pub code: String,
93    pub message: String,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub path: Option<String>,
96}
97
98impl ValidationError {
99    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
100        Self {
101            code: code.into(),
102            message: message.into(),
103            path: None,
104        }
105    }
106
107    pub fn with_path(mut self, path: impl Into<String>) -> Self {
108        self.path = Some(path.into());
109        self
110    }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
114pub struct ValidationResult {
115    pub status: ValidationStatus,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub errors: Option<Vec<ValidationError>>,
118}
119
120impl ValidationResult {
121    pub fn valid() -> Self {
122        Self {
123            status: ValidationStatus::Valid,
124            errors: None,
125        }
126    }
127
128    pub fn invalid(errors: Vec<ValidationError>) -> Self {
129        Self {
130            status: ValidationStatus::Invalid,
131            errors: Some(errors),
132        }
133    }
134
135    pub fn unknown() -> Self {
136        Self {
137            status: ValidationStatus::Unknown,
138            errors: None,
139        }
140    }
141
142    pub fn skipped() -> Self {
143        Self {
144            status: ValidationStatus::Skipped,
145            errors: None,
146        }
147    }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct AnalysisMetadata {
152    pub content_type: ContentType,
153    pub validation: ValidationResult,
154}
155
156pub fn analyze(input: &str) -> AnalysisMetadata {
157    let content_type = detect_content_type(input);
158    let validation = validate_content(content_type, input);
159    AnalysisMetadata {
160        content_type,
161        validation,
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::{ContentType, ValidationStatus, analyze};
168
169    fn assert_invalid(input: &str, expected_type: ContentType, expected_code: &str) {
170        let result = analyze(input);
171        assert_eq!(result.content_type, expected_type);
172        assert_eq!(result.validation.status, ValidationStatus::Invalid);
173        let errors = result
174            .validation
175            .errors
176            .as_ref()
177            .expect("invalid result must include errors");
178        assert_eq!(errors[0].code, expected_code);
179    }
180
181    #[test]
182    fn analyze_valid_url() {
183        let result = analyze("https://example.com/docs?q=1");
184        assert_eq!(result.content_type, ContentType::Url);
185        assert_eq!(result.validation.status, ValidationStatus::Valid);
186        assert!(result.validation.errors.is_none());
187    }
188
189    #[test]
190    fn analyze_invalid_url() {
191        assert_invalid("https://", ContentType::Url, "invalid-url");
192    }
193
194    #[test]
195    fn analyze_detects_json_before_yaml() {
196        let input = r#"{"name":"memo","priority":1}"#;
197        let result = analyze(input);
198        assert_eq!(result.content_type, ContentType::Json);
199        assert_eq!(result.validation.status, ValidationStatus::Valid);
200    }
201
202    #[test]
203    fn analyze_invalid_json() {
204        assert_invalid(r#"{"name":"memo""#, ContentType::Json, "invalid-json");
205    }
206
207    #[test]
208    fn analyze_valid_yaml() {
209        let input = "name: memo\npriority: high";
210        let result = analyze(input);
211        assert_eq!(result.content_type, ContentType::Yaml);
212        assert_eq!(result.validation.status, ValidationStatus::Valid);
213    }
214
215    #[test]
216    fn analyze_invalid_yaml() {
217        assert_invalid(
218            "name: memo\n\tpriority: high",
219            ContentType::Yaml,
220            "invalid-yaml",
221        );
222    }
223
224    #[test]
225    fn analyze_valid_xml() {
226        let result = analyze("<root><item>memo</item></root>");
227        assert_eq!(result.content_type, ContentType::Xml);
228        assert_eq!(result.validation.status, ValidationStatus::Valid);
229    }
230
231    #[test]
232    fn analyze_invalid_xml() {
233        assert_invalid("<root><item>memo</root>", ContentType::Xml, "invalid-xml");
234    }
235
236    #[test]
237    fn analyze_valid_markdown() {
238        let input = "# Inbox\n\n- [ ] buy milk";
239        let result = analyze(input);
240        assert_eq!(result.content_type, ContentType::Markdown);
241        assert_eq!(result.validation.status, ValidationStatus::Valid);
242    }
243
244    #[test]
245    fn analyze_invalid_markdown() {
246        let input = "```rust\nfn main() {}\n";
247        assert_invalid(input, ContentType::Markdown, "invalid-markdown");
248    }
249
250    #[test]
251    fn analyze_plain_text_fallback() {
252        let result = analyze("buy milk tomorrow");
253        assert_eq!(result.content_type, ContentType::Text);
254        assert_eq!(result.validation.status, ValidationStatus::Skipped);
255        assert!(result.validation.errors.is_none());
256    }
257
258    #[test]
259    fn analyze_empty_input_fallback() {
260        let result = analyze("   \n\t");
261        assert_eq!(result.content_type, ContentType::Unknown);
262        assert_eq!(result.validation.status, ValidationStatus::Unknown);
263        assert!(result.validation.errors.is_none());
264    }
265}