omni-dev 0.46.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, Datadog, Gmail, and Drive.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//! Wire types for the Drive v3 REST API.
//!
//! Field naming follows Drive's camelCase JSON via per-field
//! `#[serde(rename = "...")]`, mirroring `src/gmail/types.rs`. `size` is
//! Drive's own wire format: a **decimal string**, not a JSON number —
//! present only for binary files with actual byte content; absent for
//! folders and Google-native documents (Docs/Sheets/Slides/...), which have
//! no fixed byte size. The content-hash fields (`md5Checksum`/
//! `sha1Checksum`/`sha256Checksum`) share that same binary-content-only
//! availability but, unlike `size`, are plain lowercase hex strings with no
//! wire-format quirk of their own.

use std::collections::HashMap;
use std::io::Write;

use anyhow::Result;
use serde::{Deserialize, Serialize};

use crate::cli::drive::format::{write_scalar_jsonl, JsonlSerialize};

/// MIME type marking a Drive folder.
///
/// A shared constant (issue #1574) — previously duplicated privately in
/// `file_move.rs` (whose own doc comment explained the duplication was to
/// avoid an engine→CLI dependency on `crate::cli::drive::read::GOOGLE_FOLDER`,
/// not to avoid sharing between engine modules) and in the new
/// `permissions/check.rs`/`permissions/lookup_folder.rs`. `read.rs`'s own
/// `GOOGLE_FOLDER` constant is untouched — this is a distinct, engine-layer
/// copy, not a rename of that one.
pub(crate) const GOOGLE_FOLDER_MIME_TYPE: &str = "application/vnd.google-apps.folder";

/// MIME type marking a Google Sheet.
///
/// Engine-layer copy, for the same reason [`GOOGLE_FOLDER_MIME_TYPE`] is one:
/// `crate::cli::drive::read`'s `GOOGLE_SHEET` is a CLI-layer constant, and an
/// engine module depending on it would invert the layering. Not a rename of
/// that one — both stay.
pub(crate) const GOOGLE_SHEET_MIME_TYPE: &str = "application/vnd.google-apps.spreadsheet";

/// MIME type marking a Google Doc.
///
/// Engine-layer copy, for the same reason [`GOOGLE_FOLDER_MIME_TYPE`] and
/// [`GOOGLE_SHEET_MIME_TYPE`] are: `crate::cli::drive::read`'s `GOOGLE_DOC`
/// is a CLI-layer constant, and an engine module depending on it would
/// invert the layering. Not a rename of that one — both stay.
pub(crate) const GOOGLE_DOC_MIME_TYPE: &str = "application/vnd.google-apps.document";

/// MIME type marking a Google Slides presentation.
///
/// Used by Slides target classification and native-document refusals.
pub(crate) const GOOGLE_SLIDES_MIME_TYPE: &str = "application/vnd.google-apps.presentation";

/// MIME type marking a Drive shortcut.
///
/// Shortcuts are never followed: a shortcut to a spreadsheet is refused with
/// its own message rather than being silently resolved, matching
/// `drive read --content`'s existing behaviour.
pub(crate) const GOOGLE_SHORTCUT_MIME_TYPE: &str = "application/vnd.google-apps.shortcut";

/// The refusals every Sheets-mutating engine (`drive sheets
/// write`/`append`/`clear`, and `drive sheets add-sheet`/`rename-sheet`/
/// `insert-rows`/`insert-columns`) can reach for a target: two classified
/// **before** the write-permission gate ever runs (a shortcut, a
/// non-spreadsheet), and one — [`Self::NoVisibleParents`] — that the gate
/// itself reports, since a `file_id` rule must be consulted before "no
/// visible parent" can be the real story (issue #1612).
///
/// Shared via [`classify_sheet_target`] so the two pre-gate checks and
/// their order are written once rather than copied by hand into each
/// engine, where they would otherwise be free to quietly drift apart.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SheetTargetRefusal {
    /// The target is a shortcut, which no Sheets engine follows.
    Shortcut,
    /// The target is not a Google Sheet at all.
    NotASpreadsheet {
        /// The target's actual MIME type.
        mime_type: String,
    },
    /// The target has no parents this account can see **and** no `file_id`
    /// rule named it, so no rule the operator could write would ever grant
    /// it. Not produced by [`classify_sheet_target`] — an empty
    /// `parents` list alone is not conclusive, since a `file_id` rule
    /// sitting at depth −1 could still grant the target; only the gate
    /// step (which checks that rule first) can tell the two apart.
    NoVisibleParents,
}

/// Classifies `target` against the two pre-gate checks above. `Ok(())`
/// means neither applies and the caller should proceed to the
/// write-permission gate next — which may still report
/// [`SheetTargetRefusal::NoVisibleParents`] itself.
pub(crate) fn classify_sheet_target(target: &DriveFile) -> Result<(), SheetTargetRefusal> {
    if target.mime_type == GOOGLE_SHORTCUT_MIME_TYPE {
        return Err(SheetTargetRefusal::Shortcut);
    }
    if target.mime_type != GOOGLE_SHEET_MIME_TYPE {
        return Err(SheetTargetRefusal::NotASpreadsheet {
            mime_type: target.mime_type.clone(),
        });
    }
    Ok(())
}

/// An owner of a Drive file, as embedded in `files.list`/`files.get`'s
/// `owners[]` field (requested via the `fields` param's
/// `owners(displayName,emailAddress)` sub-selector — see `files_api.rs`).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct Owner {
    /// The owner's display name.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "displayName"
    )]
    pub display_name: Option<String>,
    /// The owner's email address.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "emailAddress"
    )]
    pub email_address: Option<String>,
}

/// A Drive file (or folder, or Google-native document) — the `files`
/// resource returned by `files.list`/`files.get`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DriveFile {
    /// Drive file id.
    pub id: String,
    /// Display name.
    pub name: String,
    /// MIME type. `application/vnd.google-apps.*` marks a Google-native
    /// document (Docs/Sheets/Slides/...) with no fixed byte content.
    #[serde(default, rename = "mimeType")]
    pub mime_type: String,
    /// Whether the file is in Trash, explicitly or through a trashed parent.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trashed: Option<bool>,
    /// Size in bytes, as a decimal string. Absent for folders and
    /// Google-native documents.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub size: Option<String>,
    /// MD5 checksum of the file's content, as a lowercase hex string.
    /// Absent for folders and Google-native documents. Has the broadest
    /// historical coverage of the three checksum fields — sha1/sha256 were
    /// added to the Drive API later, so a very old, untouched file may lack
    /// them while still carrying this one.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "md5Checksum"
    )]
    pub md5_checksum: Option<String>,
    /// SHA-1 checksum of the file's content, as a lowercase hex string.
    /// Same availability caveats as [`Self::md5_checksum`].
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "sha1Checksum"
    )]
    pub sha1_checksum: Option<String>,
    /// SHA-256 checksum of the file's content, as a lowercase hex string.
    /// Same availability caveats as [`Self::md5_checksum`].
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "sha256Checksum"
    )]
    pub sha256_checksum: Option<String>,
    /// Last modification time (RFC 3339).
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "modifiedTime"
    )]
    pub modified_time: Option<String>,
    /// A monotonically increasing version number for the file, incremented
    /// on every change including ones that do not modify content (e.g. a
    /// permission change) — Drive's own opaque revision counter, requested
    /// so the Drive write lease ([ADR-0080](../../docs/adrs/adr-0080.md)
    /// §6) can detect that a file moved since a lease was acquired or last
    /// written under. An arbitrary-precision decimal string per Drive's own
    /// documentation, so it is compared for equality only, never parsed as
    /// a number.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    /// Ids of the parent folders containing this file.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub parents: Vec<String>,
    /// A link for opening this file in a relevant Google editor or viewer.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "webViewLink"
    )]
    pub web_view_link: Option<String>,
    /// The file's owners.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub owners: Vec<Owner>,
    /// Id of the shared drive this file lives on, if any.
    #[serde(default, skip_serializing_if = "Option::is_none", rename = "driveId")]
    pub drive_id: Option<String>,
    /// Export links for a Google-native document, keyed by export MIME
    /// type. Present only on `files.get` (requested in `GET_FIELDS`;
    /// deliberately **not** requested by `files.list`'s `LIST_FIELDS` — it's
    /// irrelevant to a search-result table and would bloat every list
    /// response). Values are export URLs (unused — Drive's `files.export`
    /// endpoint is called directly instead); `drive read`'s content-export
    /// error path lists these keys when a Google-native file has no default
    /// export MIME type.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "exportLinks"
    )]
    pub export_links: Option<HashMap<String, String>>,
}

impl DriveFile {
    /// Whether this is a Google-native document (Docs/Sheets/Slides/Forms/
    /// Drawings/...) with no fixed byte content — must be fetched via
    /// `files.export`, never `files.get?alt=media`.
    #[must_use]
    pub fn is_google_native(&self) -> bool {
        self.mime_type.starts_with("application/vnd.google-apps.")
    }
}

/// Response envelope for `GET /drive/v3/files`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct FileListResponse {
    /// Matching files on this page.
    #[serde(default)]
    pub files: Vec<DriveFile>,
    /// Cursor for the next page, when more results are available.
    /// [`crate::drive::files_api::FilesApi::search_all`] clears this rather
    /// than leaving it pointing past files it discarded when a
    /// caller-supplied limit truncates the result — `None` here means
    /// either no more results exist upstream, or the search was capped,
    /// never a false invitation to keep paging.
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "nextPageToken"
    )]
    pub next_page_token: Option<String>,
    /// Whether the search process was incomplete (partial results returned
    /// due to a transient issue on Google's side). Also cleared by
    /// [`crate::drive::files_api::FilesApi::search_all`] when truncation
    /// discards fetched files, for the same reason as
    /// [`Self::next_page_token`].
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "incompleteSearch"
    )]
    pub incomplete_search: Option<bool>,
}

impl JsonlSerialize for DriveFile {
    fn write_jsonl(&self, out: &mut dyn Write) -> Result<()> {
        write_scalar_jsonl(self, out)
    }
}

/// A Drive permission, as returned by `permissions.list(fileId)`.
///
/// The building block `crate::drive::visibility` diffs to detect a move's
/// effect on a file's visibility. Fetched by
/// `crate::drive::permissions_api::PermissionsApi::list_all`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DrivePermission {
    /// Permission id.
    #[serde(default)]
    pub id: String,
    /// `"user"` / `"group"` / `"domain"` / `"anyone"`.
    #[serde(default, rename = "type")]
    pub permission_type: String,
    /// The granted role (`"reader"`/`"writer"`/`"owner"`/...). Not used by
    /// the visibility-diff algorithm — `crate::drive::visibility::Principal`
    /// deliberately excludes role from its identity — kept only for
    /// informational logging.
    #[serde(default)]
    pub role: String,
    /// The user's or group's email address (`type: "user"`/`"group"` only).
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        rename = "emailAddress"
    )]
    pub email_address: Option<String>,
    /// The Workspace domain (`type: "domain"` only).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub domain: Option<String>,
}

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
    use super::*;

    #[test]
    fn file_list_response_deserializes_a_realistic_fixture() {
        let json = serde_json::json!({
            "files": [
                {
                    "id": "f1",
                    "name": "report.pdf",
                    "mimeType": "application/pdf",
                    "size": "12345",
                    "md5Checksum": "5d41402abc4b2a76b9719d911017c592",
                    "sha1Checksum": "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d",
                    "sha256Checksum": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
                    "modifiedTime": "2026-01-01T00:00:00.000Z",
                    "parents": ["folder1"],
                    "webViewLink": "https://drive.google.com/file/d/f1/view",
                    "owners": [{"displayName": "Alice", "emailAddress": "alice@example.com"}],
                    "driveId": "shared1",
                },
            ],
            "nextPageToken": "page2",
            "incompleteSearch": false,
        });
        let response: FileListResponse = serde_json::from_value(json).unwrap();
        assert_eq!(response.files.len(), 1);
        let file = &response.files[0];
        assert_eq!(file.id, "f1");
        assert_eq!(file.size.as_deref(), Some("12345"));
        assert_eq!(
            file.md5_checksum.as_deref(),
            Some("5d41402abc4b2a76b9719d911017c592")
        );
        assert_eq!(
            file.sha1_checksum.as_deref(),
            Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d")
        );
        assert_eq!(
            file.sha256_checksum.as_deref(),
            Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
        );
        assert_eq!(
            file.owners[0].email_address.as_deref(),
            Some("alice@example.com")
        );
        assert_eq!(file.drive_id.as_deref(), Some("shared1"));
        assert_eq!(response.next_page_token.as_deref(), Some("page2"));
    }

    #[test]
    fn checksums_are_none_when_absent() {
        let json = serde_json::json!({"id": "f1", "name": "n"});
        let file: DriveFile = serde_json::from_value(json).unwrap();
        assert!(file.md5_checksum.is_none());
        assert!(file.sha1_checksum.is_none());
        assert!(file.sha256_checksum.is_none());
    }

    #[test]
    fn size_round_trips_as_a_string_not_a_number() {
        let file = DriveFile {
            id: "f1".to_string(),
            name: "n".to_string(),
            size: Some("999".to_string()),
            ..Default::default()
        };
        let value = serde_json::to_value(&file).unwrap();
        assert_eq!(value["size"], serde_json::json!("999"));
    }

    #[test]
    fn is_google_native_true_for_google_apps_mime_type() {
        let file = DriveFile {
            mime_type: "application/vnd.google-apps.document".to_string(),
            ..Default::default()
        };
        assert!(file.is_google_native());
    }

    #[test]
    fn is_google_native_false_for_ordinary_mime_type() {
        let file = DriveFile {
            mime_type: "application/pdf".to_string(),
            ..Default::default()
        };
        assert!(!file.is_google_native());
    }

    #[test]
    fn drive_file_deserializes_from_minimal_fields() {
        let json = serde_json::json!({"id": "f1", "name": "n"});
        let file: DriveFile = serde_json::from_value(json).unwrap();
        assert_eq!(file.id, "f1");
        assert_eq!(file.mime_type, "");
        assert!(file.size.is_none());
        assert!(file.export_links.is_none());
    }

    #[test]
    fn deserializing_unmodeled_extra_field_still_succeeds() {
        let json = serde_json::json!({
            "id": "f1",
            "name": "n",
            "somethingNew": {"nested": true},
        });
        let file: DriveFile = serde_json::from_value(json).unwrap();
        assert_eq!(file.id, "f1");
    }

    #[test]
    fn export_links_parses_google_native_export_map() {
        let json = serde_json::json!({
            "id": "f1",
            "name": "doc",
            "mimeType": "application/vnd.google-apps.document",
            "exportLinks": {
                "text/markdown": "https://export.example/md",
                "application/pdf": "https://export.example/pdf",
            },
        });
        let file: DriveFile = serde_json::from_value(json).unwrap();
        let links = file.export_links.unwrap();
        assert_eq!(links.len(), 2);
        assert!(links.contains_key("text/markdown"));
    }

    #[test]
    fn drive_file_write_jsonl_emits_exactly_one_line() {
        let file = DriveFile {
            id: "f1".to_string(),
            name: "n".to_string(),
            ..Default::default()
        };
        let mut buf = Vec::new();
        file.write_jsonl(&mut buf).unwrap();
        let text = String::from_utf8(buf).unwrap();
        assert_eq!(text.lines().count(), 1);
        assert!(text.contains("f1"));
    }

    // ── classify_sheet_target ───────────────────────────────────────────

    fn sheet_target(parents: &[&str]) -> DriveFile {
        DriveFile {
            id: "sheet-1".to_string(),
            name: "Budget".to_string(),
            mime_type: GOOGLE_SHEET_MIME_TYPE.to_string(),
            parents: parents.iter().map(ToString::to_string).collect(),
            ..Default::default()
        }
    }

    #[test]
    fn classify_sheet_target_allows_a_spreadsheet_with_a_visible_parent() {
        assert_eq!(classify_sheet_target(&sheet_target(&["parent-1"])), Ok(()));
    }

    #[test]
    fn classify_sheet_target_refuses_a_shortcut_even_to_a_spreadsheet() {
        let mut target = sheet_target(&["parent-1"]);
        target.mime_type = GOOGLE_SHORTCUT_MIME_TYPE.to_string();
        assert_eq!(
            classify_sheet_target(&target),
            Err(SheetTargetRefusal::Shortcut)
        );
    }

    #[test]
    fn classify_sheet_target_refuses_a_non_spreadsheet() {
        let mut target = sheet_target(&["parent-1"]);
        target.mime_type = "application/pdf".to_string();
        assert_eq!(
            classify_sheet_target(&target),
            Err(SheetTargetRefusal::NotASpreadsheet {
                mime_type: "application/pdf".to_string()
            })
        );
    }

    #[test]
    fn classify_sheet_target_allows_a_spreadsheet_with_no_visible_parents() {
        // `NoVisibleParents` is now reported by the gate step, not here —
        // an empty `parents` list alone isn't conclusive, since a
        // `file_id` rule could still grant the target (issue #1612). See
        // `target_gate.rs`'s tests for that decision.
        assert_eq!(classify_sheet_target(&sheet_target(&[])), Ok(()));
    }
}