Skip to main content

omni_dev/drive/docs/
target.rs

1//! Classifying a Drive file id for the Docs commands, and wording the
2//! refusal when it is not a Google Doc.
3//!
4//! Shared by the read verbs and (later) the gated write verbs, which need
5//! the identical classification plus the target's `parents` for the
6//! permission gate — so the mime-type reasoning lives in one place rather
7//! than being copy-pasted into each engine.
8//!
9//! These are *nonsense* refusals, not policy ones: `drive docs read` on a
10//! spreadsheet is not disallowed, it is meaningless. That is the same
11//! distinction `content_edit.rs`'s Google-native refusal draws and
12//! `sheets/write.rs`'s inverts, and it is why they are checked before any
13//! gate rather than through one.
14
15use anyhow::Result;
16
17use crate::drive::files_api::FilesApi;
18use crate::drive::types::{
19    DriveFile, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, GOOGLE_SHORTCUT_MIME_TYPE,
20    GOOGLE_SLIDES_MIME_TYPE,
21};
22
23/// What a Drive file id turned out to be.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum DocTarget {
26    /// A Google Doc. Carries the full metadata, so a write engine can read
27    /// `parents` for the gate without a second `files.get`.
28    Document(Box<DriveFile>),
29    /// A shortcut. Never followed — its own variant, because "this is not a
30    /// Google Doc" is a confusing thing to say about a shortcut *to* one.
31    Shortcut {
32        /// The shortcut's name, for the message.
33        name: String,
34    },
35    /// Anything else.
36    NotADocument {
37        /// The file's name, for the message.
38        name: String,
39        /// Its mime type, which is what makes a targeted hint possible.
40        mime_type: String,
41    },
42}
43
44impl DocTarget {
45    /// Classifies already-fetched metadata.
46    ///
47    /// Split from [`classify`] so the mime-type reasoning is testable
48    /// without a server, and so a write engine that already holds the
49    /// metadata does not fetch it twice.
50    #[must_use]
51    pub fn of(file: DriveFile) -> Self {
52        // Shortcut first: a shortcut *to* a Doc would otherwise fall through
53        // to the generic arm and be described as "not a Google Doc", which
54        // is both true and useless.
55        if file.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
56            return Self::Shortcut { name: file.name };
57        }
58        if file.mime_type == GOOGLE_DOC_MIME_TYPE {
59            return Self::Document(Box::new(file));
60        }
61        Self::NotADocument {
62            name: file.name,
63            mime_type: file.mime_type,
64        }
65    }
66
67    /// The document's metadata, when this is one.
68    #[must_use]
69    pub fn document(&self) -> Option<&DriveFile> {
70        match self {
71            Self::Document(file) => Some(file),
72            _ => None,
73        }
74    }
75
76    /// The refusal message, or `None` when this *is* a Google Doc.
77    ///
78    /// `verb` is the CLI spelling (`read`, `info`, …) so the message names
79    /// the command the user actually typed.
80    #[must_use]
81    pub fn refusal_message(&self, verb: &str) -> Option<String> {
82        match self {
83            Self::Document(_) => None,
84            Self::Shortcut { name } => Some(format!(
85                "Refused: '{name}' is a shortcut; `drive docs {verb}` doesn't follow shortcuts — \
86                 resolve the target document's id and use that instead"
87            )),
88            Self::NotADocument { name, mime_type } => {
89                let base = format!(
90                    "Refused: '{name}' is not a Google Doc (mimeType: {mime_type}); \
91                     `drive docs {verb}` only works on Google Docs"
92                );
93                // The mime type is known, so pointing at the command that
94                // *does* work costs one match arm and saves a support
95                // round-trip.
96                match mime_type.as_str() {
97                    GOOGLE_SHEET_MIME_TYPE => {
98                        Some(format!("{base} — try `omni-dev drive sheets read` instead"))
99                    }
100                    GOOGLE_SLIDES_MIME_TYPE => {
101                        Some(format!("{base} — try `omni-dev drive slides read` instead"))
102                    }
103                    _ => Some(base),
104                }
105            }
106        }
107    }
108}
109
110/// Fetches a file's metadata and classifies it.
111pub async fn classify(api: &FilesApi<'_>, file_id: &str) -> Result<DocTarget> {
112    Ok(DocTarget::of(api.get_metadata(file_id).await?))
113}
114
115/// Replaces `err` with a Docs-specific refusal when `files.get` explains it.
116///
117/// The read verbs classify **lazily**: they call `documents.get` first and
118/// only reach for `files.get` once it has already failed. An eager preflight
119/// would double the request count on every *successful* read to improve a
120/// message that only appears on an unsuccessful one — which is exactly why
121/// `sheets read` does no preflight and only `sheets write` does, and there
122/// only because the gate needs `parents` anyway.
123///
124/// Any failure to classify leaves the original error untouched: a better
125/// message is a nicety, and losing the real error to chase one would be a
126/// bad trade.
127pub async fn explain_failure(
128    api: &FilesApi<'_>,
129    file_id: &str,
130    verb: &str,
131    err: anyhow::Error,
132) -> anyhow::Error {
133    match classify(api, file_id).await {
134        Ok(target) => target
135            .refusal_message(verb)
136            .map_or(err, |message| anyhow::anyhow!(message)),
137        Err(_) => err,
138    }
139}
140
141#[cfg(test)]
142#[allow(clippy::unwrap_used, clippy::expect_used)]
143mod tests {
144    use super::*;
145
146    fn file(name: &str, mime_type: &str) -> DriveFile {
147        DriveFile {
148            id: "d1".to_string(),
149            name: name.to_string(),
150            mime_type: mime_type.to_string(),
151            ..DriveFile::default()
152        }
153    }
154
155    #[test]
156    fn classify_recognises_a_google_doc() {
157        let target = DocTarget::of(file("Design Doc", GOOGLE_DOC_MIME_TYPE));
158        assert!(target.refusal_message("read").is_none());
159        assert_eq!(target.document().unwrap().name, "Design Doc");
160    }
161
162    /// A shortcut *to* a Doc would otherwise be described as "not a Google
163    /// Doc", which is true and useless.
164    #[test]
165    fn classify_checks_shortcut_before_the_mime_check() {
166        let target = DocTarget::of(file("Design Doc (link)", GOOGLE_SHORTCUT_MIME_TYPE));
167        assert!(matches!(target, DocTarget::Shortcut { .. }));
168        let message = target.refusal_message("read").unwrap();
169        assert!(message.contains("is a shortcut"), "{message}");
170        assert!(message.contains("doesn't follow shortcuts"), "{message}");
171        assert!(!message.contains("not a Google Doc"), "{message}");
172    }
173
174    #[test]
175    fn refusal_for_a_spreadsheet_points_at_drive_sheets_read() {
176        let message = DocTarget::of(file("Q3", GOOGLE_SHEET_MIME_TYPE))
177            .refusal_message("read")
178            .unwrap();
179        assert!(message.contains("not a Google Doc"), "{message}");
180        assert!(message.contains("drive sheets read"), "{message}");
181    }
182
183    #[test]
184    fn refusal_for_a_presentation_points_at_drive_slides() {
185        let message = DocTarget::of(file("Deck", GOOGLE_SLIDES_MIME_TYPE))
186            .refusal_message("read")
187            .unwrap();
188        assert!(message.contains("drive slides read"), "{message}");
189    }
190
191    #[test]
192    fn refusal_for_a_plain_file_names_its_mime_type_without_a_hint() {
193        let message = DocTarget::of(file("notes.txt", "text/plain"))
194            .refusal_message("read")
195            .unwrap();
196        assert!(message.contains("text/plain"), "{message}");
197        assert!(!message.contains("try `omni-dev"), "{message}");
198    }
199
200    /// The message names the command the user actually typed.
201    #[test]
202    fn refusal_names_the_verb() {
203        let target = DocTarget::of(file("Q3", GOOGLE_SHEET_MIME_TYPE));
204        assert!(target
205            .refusal_message("info")
206            .unwrap()
207            .contains("drive docs info"));
208        assert!(target
209            .refusal_message("read")
210            .unwrap()
211            .contains("drive docs read"));
212    }
213}