Skip to main content

omni_dev/drive/slides/
target.rs

1//! Classifying a Drive file id for the Slides commands, and wording the
2//! refusal when it is not a Google Slides presentation.
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 slides 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 SlidesTarget {
26    /// A Google Slides presentation. Carries the full metadata, so a write engine can read
27    /// `parents` for the gate without a second `files.get`.
28    Presentation(Box<DriveFile>),
29    /// A shortcut. Never followed — its own variant, because "this is not a
30    /// Google Slides presentation" 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    NotAPresentation {
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 SlidesTarget {
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 presentation would otherwise fall through
53        // to the generic arm and be described as "not a Google Slides presentation", 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_SLIDES_MIME_TYPE {
59            return Self::Presentation(Box::new(file));
60        }
61        Self::NotAPresentation {
62            name: file.name,
63            mime_type: file.mime_type,
64        }
65    }
66
67    /// The presentation's metadata, when this is one.
68    #[must_use]
69    pub fn presentation(&self) -> Option<&DriveFile> {
70        match self {
71            Self::Presentation(file) => Some(file),
72            _ => None,
73        }
74    }
75
76    /// The refusal message, or `None` when this *is* a Google Slides presentation.
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::Presentation(_) => None,
84            Self::Shortcut { name } => Some(format!(
85                "Refused: '{name}' is a shortcut; `drive slides {verb}` doesn't follow shortcuts — \
86                 resolve the target presentation's id and use that instead"
87            )),
88            Self::NotAPresentation { name, mime_type } => {
89                let base = format!(
90                    "Refused: '{name}' is not a Google Slides presentation (mimeType: {mime_type}); \
91                     `drive slides {verb}` only works on Google Slides"
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_DOC_MIME_TYPE => {
101                        Some(format!("{base} — try `omni-dev drive docs 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<SlidesTarget> {
112    Ok(SlidesTarget::of(api.get_metadata(file_id).await?))
113}
114
115/// Replaces `err` with a Slides-specific refusal when `files.get` explains it.
116///
117/// The read verbs classify **lazily**: they call `presentations.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_presentation() {
157        let target = SlidesTarget::of(file("Deck", GOOGLE_SLIDES_MIME_TYPE));
158        assert!(target.refusal_message("read").is_none());
159        assert_eq!(target.presentation().unwrap().name, "Deck");
160    }
161
162    /// A shortcut *to* a presentation would otherwise be described as "not a Google
163    /// Slides presentation", which is true and useless.
164    #[test]
165    fn classify_checks_shortcut_before_the_mime_check() {
166        let target = SlidesTarget::of(file("Deck (link)", GOOGLE_SHORTCUT_MIME_TYPE));
167        assert!(matches!(target, SlidesTarget::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!(
172            !message.contains("not a Google Slides presentation"),
173            "{message}"
174        );
175    }
176
177    #[test]
178    fn refusal_for_a_spreadsheet_points_at_drive_sheets_read() {
179        let message = SlidesTarget::of(file("Q3", GOOGLE_SHEET_MIME_TYPE))
180            .refusal_message("read")
181            .unwrap();
182        assert!(
183            message.contains("not a Google Slides presentation"),
184            "{message}"
185        );
186        assert!(message.contains("drive sheets read"), "{message}");
187    }
188
189    #[test]
190    fn refusal_for_a_doc_points_at_drive_docs() {
191        let message = SlidesTarget::of(file("Deck", GOOGLE_DOC_MIME_TYPE))
192            .refusal_message("read")
193            .unwrap();
194        assert!(message.contains("drive docs read"), "{message}");
195    }
196
197    #[test]
198    fn refusal_for_a_plain_file_names_its_mime_type_without_a_hint() {
199        let message = SlidesTarget::of(file("notes.txt", "text/plain"))
200            .refusal_message("read")
201            .unwrap();
202        assert!(message.contains("text/plain"), "{message}");
203        assert!(!message.contains("try `omni-dev"), "{message}");
204    }
205
206    /// The message names the command the user actually typed.
207    #[test]
208    fn refusal_names_the_verb() {
209        let target = SlidesTarget::of(file("Q3", GOOGLE_SHEET_MIME_TYPE));
210        assert!(target
211            .refusal_message("info")
212            .unwrap()
213            .contains("drive slides info"));
214        assert!(target
215            .refusal_message("read")
216            .unwrap()
217            .contains("drive slides read"));
218    }
219}