1use crate::{Availability, IntegrationError};
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13#[non_exhaustive]
14pub struct NoteAccount {
15 pub id: String,
16 pub name: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[non_exhaustive]
21pub struct NoteSummary {
22 pub id: String,
23 pub name: String,
24 pub folder: Option<String>,
25 pub modified: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[non_exhaustive]
30pub struct NotesAccountListing {
31 #[serde(flatten)]
32 pub availability: Availability,
33 pub accounts: Vec<NoteAccount>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[non_exhaustive]
38pub struct NotesListing {
39 #[serde(flatten)]
40 pub availability: Availability,
41 pub notes: Vec<NoteSummary>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct ReminderList {
47 pub id: String,
48 pub name: String,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize)]
52#[non_exhaustive]
53pub struct ReminderItem {
54 pub id: String,
55 pub name: String,
56 pub list: Option<String>,
57 pub due: Option<String>,
58 pub completed: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[non_exhaustive]
63pub struct ReminderListListing {
64 #[serde(flatten)]
65 pub availability: Availability,
66 pub lists: Vec<ReminderList>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[non_exhaustive]
71pub struct ReminderItemListing {
72 #[serde(flatten)]
73 pub availability: Availability,
74 pub reminders: Vec<ReminderItem>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[non_exhaustive]
79pub struct PhotoAlbum {
80 pub id: String,
81 pub name: String,
82 pub count: Option<u32>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[non_exhaustive]
87pub struct PhotoAlbumListing {
88 #[serde(flatten)]
89 pub availability: Availability,
90 pub albums: Vec<PhotoAlbum>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[non_exhaustive]
95pub struct Bookmark {
96 pub title: String,
97 pub url: Option<String>,
98 pub source: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct BookmarkListing {
104 #[serde(flatten)]
105 pub availability: Availability,
106 pub bookmarks: Vec<Bookmark>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[non_exhaustive]
111pub struct FileLocation {
112 pub id: String,
113 pub name: String,
114 pub path: String,
115 pub exists: bool,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
119#[non_exhaustive]
120pub struct FileLocationListing {
121 #[serde(flatten)]
122 pub availability: Availability,
123 pub locations: Vec<FileLocation>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[non_exhaustive]
128pub struct KeychainStatus {
129 #[serde(flatten)]
130 pub availability: Availability,
131}
132
133pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
134 backend::notes_accounts()
135}
136
137pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
138 backend::notes_find(query, limit)
139}
140
141pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
142 backend::reminders_lists()
143}
144
145pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
146 backend::reminders_items(limit)
147}
148
149pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
150 backend::photos_albums()
151}
152
153pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
154 backend::bookmarks_list(limit)
155}
156
157pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
158 backend::files_locations()
159}
160
161pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
162 backend::keychain_status()
163}
164
165#[cfg(target_os = "macos")]
166mod backend {
167 use super::*;
168 use serde_json::Value;
169 use std::path::PathBuf;
170 use std::process::Command;
171
172 const NOTES_JXA: &str = r#"
173function app() { const a = Application("/System/Applications/Notes.app"); a.includeStandardAdditions = true; return a; }
174function accountOut(a) {
175 let id = ""; let name = "";
176 try { id = String(a.id()); } catch (e) {}
177 try { name = String(a.name()); } catch (e) {}
178 return {id: id || name, name: name || id};
179}
180function noteOut(n) {
181 let id = ""; let name = ""; let folder = null; let modified = null;
182 try { id = String(n.id()); } catch (e) {}
183 try { name = String(n.name()); } catch (e) {}
184 try { folder = String(n.container().name()); } catch (e) {}
185 try { modified = String(n.modificationDate()); } catch (e) {}
186 return {id: id || name, name: name || id, folder: folder, modified: modified};
187}
188function run(argv) {
189 const mode = argv[0] || "accounts";
190 try {
191 const Notes = app();
192 if (mode === "accounts") {
193 return JSON.stringify({available:true, backend:"notes_app", reason:null, accounts: Notes.accounts().map(accountOut)});
194 }
195 const query = String(argv[1] || "").toLowerCase();
196 const limit = Number(argv[2] || "50");
197 let out = [];
198 Notes.accounts().forEach(a => {
199 a.notes().forEach(n => {
200 let hay = "";
201 try { hay += " " + String(n.name()).toLowerCase(); } catch (e) {}
202 try { hay += " " + String(n.plaintext()).toLowerCase(); } catch (e) {}
203 if (!query || hay.indexOf(query) >= 0) out.push(noteOut(n));
204 });
205 });
206 return JSON.stringify({available:true, backend:"notes_app", reason:null, notes: out.slice(0, limit)});
207 } catch (e) {
208 if (mode === "accounts") return JSON.stringify({available:false, backend:"notes_app", reason:String(e), accounts:[]});
209 return JSON.stringify({available:false, backend:"notes_app", reason:String(e), notes:[]});
210 }
211}
212"#;
213
214 const REMINDERS_JXA: &str = r#"
215function app() { const a = Application("/System/Applications/Reminders.app"); a.includeStandardAdditions = true; return a; }
216function listOut(l) {
217 let id = ""; let name = "";
218 try { id = String(l.id()); } catch (e) {}
219 try { name = String(l.name()); } catch (e) {}
220 return {id: id || name, name: name || id};
221}
222function itemOut(r) {
223 let id = ""; let name = ""; let list = null; let due = null; let completed = false;
224 try { id = String(r.id()); } catch (e) {}
225 try { name = String(r.name()); } catch (e) {}
226 try { list = String(r.container().name()); } catch (e) {}
227 try { due = String(r.dueDate()); } catch (e) {}
228 try { completed = !!r.completed(); } catch (e) {}
229 return {id: id || name, name: name || id, list: list, due: due, completed: completed};
230}
231function run(argv) {
232 const mode = argv[0] || "lists";
233 try {
234 const Reminders = app();
235 if (mode === "lists") {
236 return JSON.stringify({available:true, backend:"reminders_app", reason:null, lists: Reminders.lists().map(listOut)});
237 }
238 const limit = Number(argv[1] || "50");
239 let out = [];
240 Reminders.lists().forEach(l => l.reminders().forEach(r => { if (!r.completed()) out.push(itemOut(r)); }));
241 return JSON.stringify({available:true, backend:"reminders_app", reason:null, reminders: out.slice(0, limit)});
242 } catch (e) {
243 if (mode === "lists") return JSON.stringify({available:false, backend:"reminders_app", reason:String(e), lists:[]});
244 return JSON.stringify({available:false, backend:"reminders_app", reason:String(e), reminders:[]});
245 }
246}
247"#;
248
249 const PHOTOS_JXA: &str = r#"
250function run(argv) {
251 try {
252 const Photos = Application("/System/Applications/Photos.app");
253 Photos.includeStandardAdditions = true;
254 const albums = Photos.albums().map(a => {
255 let id = ""; let name = ""; let count = null;
256 try { id = String(a.id()); } catch (e) {}
257 try { name = String(a.name()); } catch (e) {}
258 try { count = a.mediaItems().length; } catch (e) {}
259 return {id: id || name, name: name || id, count: count};
260 });
261 return JSON.stringify({available:true, backend:"photos_app", reason:null, albums: albums});
262 } catch (e) {
263 return JSON.stringify({available:false, backend:"photos_app", reason:String(e), albums:[]});
264 }
265}
266"#;
267
268 pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
269 Ok(
270 run_jxa(NOTES_JXA, &["accounts"]).unwrap_or_else(|e| NotesAccountListing {
271 availability: Availability::pending("notes_app", e.to_string()),
272 accounts: vec![],
273 }),
274 )
275 }
276
277 pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
278 Ok(
279 run_jxa(NOTES_JXA, &["find", query, &limit.to_string()]).unwrap_or_else(|e| {
280 NotesListing {
281 availability: Availability::pending("notes_app", e.to_string()),
282 notes: vec![],
283 }
284 }),
285 )
286 }
287
288 pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
289 Ok(
290 run_jxa(REMINDERS_JXA, &["lists"]).unwrap_or_else(|e| ReminderListListing {
291 availability: Availability::pending("reminders_app", e.to_string()),
292 lists: vec![],
293 }),
294 )
295 }
296
297 pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
298 Ok(
299 run_jxa(REMINDERS_JXA, &["items", &limit.to_string()]).unwrap_or_else(|e| {
300 ReminderItemListing {
301 availability: Availability::pending("reminders_app", e.to_string()),
302 reminders: vec![],
303 }
304 }),
305 )
306 }
307
308 pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
309 Ok(
310 run_jxa(PHOTOS_JXA, &[]).unwrap_or_else(|e| PhotoAlbumListing {
311 availability: Availability::pending("photos_app", e.to_string()),
312 albums: vec![],
313 }),
314 )
315 }
316
317 pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
318 let mut path = home();
319 path.push("Library/Safari/Bookmarks.plist");
320 let output = Command::new("/usr/bin/plutil")
321 .args(["-convert", "json", "-o", "-"])
322 .arg(path)
323 .output()
324 .map_err(|e| IntegrationError::Backend(format!("bookmarks plutil: {e}")))?;
325 if !output.status.success() {
326 return Ok(BookmarkListing {
327 availability: Availability::pending(
328 "safari_bookmarks",
329 String::from_utf8_lossy(&output.stderr).trim().to_string(),
330 ),
331 bookmarks: vec![],
332 });
333 }
334 let value: Value = serde_json::from_slice(&output.stdout)
335 .map_err(|e| IntegrationError::Backend(format!("bookmarks json: {e}")))?;
336 let mut bookmarks = Vec::new();
337 collect_bookmarks(&value, &mut bookmarks, limit);
338 Ok(BookmarkListing {
339 availability: Availability::available("safari_bookmarks"),
340 bookmarks,
341 })
342 }
343
344 pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
345 let mut locations = Vec::new();
346 let mut add = |id: &str, name: &str, path: PathBuf| {
347 locations.push(FileLocation {
348 id: id.to_string(),
349 name: name.to_string(),
350 exists: path.exists(),
351 path: path.to_string_lossy().to_string(),
352 });
353 };
354 let home = home();
355 add(
356 "icloud_drive",
357 "iCloud Drive",
358 home.join("Library/Mobile Documents/com~apple~CloudDocs"),
359 );
360 add("desktop", "Desktop", home.join("Desktop"));
361 add("documents", "Documents", home.join("Documents"));
362 let available = locations.iter().any(|location| location.exists);
363 Ok(FileLocationListing {
364 availability: if available {
365 Availability::available("macos_files")
366 } else {
367 Availability::pending("macos_files", "No standard macOS file locations found.")
368 },
369 locations,
370 })
371 }
372
373 pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
374 let check = car_secrets::SecretStore::new().availability();
375 Ok(KeychainStatus {
376 availability: if check.available {
377 Availability::available("keychain")
378 } else {
379 Availability::pending(
380 "keychain",
381 check
382 .reason
383 .unwrap_or_else(|| "macOS Keychain is unavailable.".to_string()),
384 )
385 },
386 })
387 }
388
389 fn run_jxa<T: serde::de::DeserializeOwned>(
397 script: &str,
398 args: &[&str],
399 ) -> Result<T, IntegrationError> {
400 crate::jxa::run(script, args, crate::jxa::DEFAULT_TIMEOUT)
401 }
402
403 fn collect_bookmarks(value: &Value, out: &mut Vec<Bookmark>, limit: usize) {
404 if out.len() >= limit {
405 return;
406 }
407 if let Some(url) = value.get("URLString").and_then(Value::as_str) {
408 let title = value
409 .get("URIDictionary")
410 .and_then(|v| v.get("title"))
411 .and_then(Value::as_str)
412 .or_else(|| value.get("Title").and_then(Value::as_str))
413 .unwrap_or(url);
414 out.push(Bookmark {
415 title: title.to_string(),
416 url: Some(url.to_string()),
417 source: "safari".to_string(),
418 });
419 }
420 if let Some(children) = value.get("Children").and_then(Value::as_array) {
421 for child in children {
422 collect_bookmarks(child, out, limit);
423 if out.len() >= limit {
424 break;
425 }
426 }
427 }
428 }
429
430 fn home() -> PathBuf {
431 PathBuf::from(std::env::var_os("HOME").unwrap_or_default())
432 }
433}
434
435#[cfg(not(target_os = "macos"))]
436mod backend {
437 use super::*;
438 use serde_json::Value;
439
440 fn graph_pending(surface: &str) -> Availability {
447 Availability::pending(
448 "msgraph",
449 format!(
450 "Set CAR_MSGRAPH_CLIENT_ID (Azure AD app) to enable the Microsoft \
451 Graph {surface} backend (car#520)."
452 ),
453 )
454 }
455
456 pub fn notes_accounts() -> Result<NotesAccountListing, IntegrationError> {
457 if crate::msgraph::is_configured() {
458 return Ok(match crate::msgraph::onenote_notebooks() {
459 Ok(nbs) => NotesAccountListing {
460 availability: Availability::available("msgraph"),
461 accounts: nbs
462 .into_iter()
463 .map(|n| NoteAccount {
464 id: n.id,
465 name: n.name,
466 })
467 .collect(),
468 },
469 Err(e) => NotesAccountListing {
470 availability: Availability::pending("msgraph", e.to_string()),
471 accounts: vec![],
472 },
473 });
474 }
475 Ok(NotesAccountListing {
476 availability: graph_pending("OneNote notes"),
477 accounts: vec![],
478 })
479 }
480
481 pub fn notes_find(query: &str, limit: usize) -> Result<NotesListing, IntegrationError> {
482 if crate::msgraph::is_configured() {
483 return Ok(match crate::msgraph::onenote_pages(query, limit.max(1)) {
484 Ok(pages) => NotesListing {
485 availability: Availability::available("msgraph"),
486 notes: pages
487 .into_iter()
488 .map(|p| NoteSummary {
489 id: p.id,
490 name: p.title,
491 folder: p.notebook,
492 modified: p.modified,
493 })
494 .collect(),
495 },
496 Err(e) => NotesListing {
497 availability: Availability::pending("msgraph", e.to_string()),
498 notes: vec![],
499 },
500 });
501 }
502 Ok(NotesListing {
503 availability: graph_pending("OneNote notes"),
504 notes: vec![],
505 })
506 }
507
508 pub fn reminders_lists() -> Result<ReminderListListing, IntegrationError> {
509 if crate::msgraph::is_configured() {
510 return Ok(match crate::msgraph::todo_lists() {
511 Ok(lists) => ReminderListListing {
512 availability: Availability::available("msgraph"),
513 lists: lists
514 .into_iter()
515 .map(|l| ReminderList {
516 id: l.id,
517 name: l.name,
518 })
519 .collect(),
520 },
521 Err(e) => ReminderListListing {
522 availability: Availability::pending("msgraph", e.to_string()),
523 lists: vec![],
524 },
525 });
526 }
527 Ok(ReminderListListing {
528 availability: graph_pending("To Do reminders"),
529 lists: vec![],
530 })
531 }
532
533 pub fn reminders_items(limit: usize) -> Result<ReminderItemListing, IntegrationError> {
534 if crate::msgraph::is_configured() {
535 return Ok(match crate::msgraph::todo_tasks(limit) {
536 Ok(tasks) => ReminderItemListing {
537 availability: Availability::available("msgraph"),
538 reminders: tasks
539 .into_iter()
540 .map(|t| ReminderItem {
541 id: t.id,
542 name: t.title,
543 list: t.list,
544 due: t.due,
545 completed: t.completed,
546 })
547 .collect(),
548 },
549 Err(e) => ReminderItemListing {
550 availability: Availability::pending("msgraph", e.to_string()),
551 reminders: vec![],
552 },
553 });
554 }
555 Ok(ReminderItemListing {
556 availability: graph_pending("To Do reminders"),
557 reminders: vec![],
558 })
559 }
560
561 pub fn photos_albums() -> Result<PhotoAlbumListing, IntegrationError> {
562 Ok(PhotoAlbumListing {
563 availability: pending("photos_app"),
564 albums: vec![],
565 })
566 }
567
568 pub fn bookmarks_list(limit: usize) -> Result<BookmarkListing, IntegrationError> {
581 let files = chromium_bookmark_files();
582 if files.is_empty() {
583 return Ok(BookmarkListing {
584 availability: Availability::pending(
585 "chromium_bookmarks",
586 "No Chromium-based browser (Chrome, Edge, or Brave) bookmarks found.",
587 ),
588 bookmarks: vec![],
589 });
590 }
591 let mut bookmarks = Vec::new();
594 for (source, file) in &files {
595 if bookmarks.len() >= limit {
596 break;
597 }
598 if let Ok(text) = std::fs::read_to_string(file) {
599 parse_chromium_bookmarks(&text, source, &mut bookmarks, limit);
600 }
601 }
602 Ok(BookmarkListing {
603 availability: Availability::available("chromium_bookmarks"),
604 bookmarks,
605 })
606 }
607
608 fn chromium_bookmark_files() -> Vec<(String, std::path::PathBuf)> {
615 use std::path::PathBuf;
616 let mut roots: Vec<(&'static str, PathBuf)> = Vec::new();
617 #[cfg(target_os = "windows")]
618 {
619 if let Some(local) = std::env::var_os("LOCALAPPDATA").map(PathBuf::from) {
620 roots.push(("chrome", local.join(r"Google\Chrome\User Data")));
621 roots.push(("edge", local.join(r"Microsoft\Edge\User Data")));
622 roots.push((
623 "brave",
624 local.join(r"BraveSoftware\Brave-Browser\User Data"),
625 ));
626 }
627 }
628 #[cfg(not(target_os = "windows"))]
629 {
630 if let Some(config) = dirs::config_dir() {
631 roots.push(("chrome", config.join("google-chrome")));
632 roots.push(("chromium", config.join("chromium")));
633 roots.push(("edge", config.join("microsoft-edge")));
634 roots.push(("brave", config.join("BraveSoftware/Brave-Browser")));
635 }
636 }
637 let mut files = Vec::new();
638 for (browser, base) in roots {
639 let Ok(entries) = std::fs::read_dir(&base) else {
640 continue;
641 };
642 for entry in entries.flatten() {
643 let profile_dir = entry.path();
644 if !profile_dir.is_dir() {
645 continue;
646 }
647 let bookmarks = profile_dir.join("Bookmarks");
648 if !bookmarks.exists() {
649 continue;
650 }
651 let profile = entry.file_name().to_string_lossy().into_owned();
652 let source = if profile == "Default" {
653 browser.to_string()
654 } else {
655 format!("{browser}:{profile}")
656 };
657 files.push((source, bookmarks));
658 }
659 }
660 files
661 }
662
663 fn parse_chromium_bookmarks(json: &str, source: &str, out: &mut Vec<Bookmark>, limit: usize) {
666 let Ok(value) = serde_json::from_str::<Value>(json) else {
667 return;
668 };
669 let Some(roots) = value.get("roots") else {
670 return;
671 };
672 for key in ["bookmark_bar", "other", "synced"] {
673 if out.len() >= limit {
674 break;
675 }
676 if let Some(node) = roots.get(key) {
677 collect_chromium(node, source, out, limit);
678 }
679 }
680 }
681
682 fn collect_chromium(node: &Value, source: &str, out: &mut Vec<Bookmark>, limit: usize) {
685 if out.len() >= limit {
686 return;
687 }
688 if node.get("type").and_then(Value::as_str) == Some("url") {
689 if let Some(url) = node.get("url").and_then(Value::as_str) {
690 let title = node
691 .get("name")
692 .and_then(Value::as_str)
693 .filter(|s| !s.is_empty())
694 .unwrap_or(url);
695 out.push(Bookmark {
696 title: title.to_string(),
697 url: Some(url.to_string()),
698 source: source.to_string(),
699 });
700 }
701 return;
702 }
703 if let Some(children) = node.get("children").and_then(Value::as_array) {
704 for child in children {
705 collect_chromium(child, source, out, limit);
706 if out.len() >= limit {
707 break;
708 }
709 }
710 }
711 }
712
713 pub fn files_locations() -> Result<FileLocationListing, IntegrationError> {
720 let mut locations = Vec::new();
721 let mut add = |id: &str, name: &str, path: Option<std::path::PathBuf>| {
722 if let Some(path) = path {
723 locations.push(FileLocation {
724 id: id.to_string(),
725 name: name.to_string(),
726 exists: path.exists(),
727 path: path.to_string_lossy().to_string(),
728 });
729 }
730 };
731 add("desktop", "Desktop", dirs::desktop_dir());
732 add("documents", "Documents", dirs::document_dir());
733 add("downloads", "Downloads", dirs::download_dir());
734 #[cfg(target_os = "windows")]
738 {
739 let onedrive = std::env::var_os("OneDrive")
740 .map(std::path::PathBuf::from)
741 .or_else(|| dirs::home_dir().map(|h| h.join("OneDrive")));
742 add("onedrive", "OneDrive", onedrive);
743 }
744
745 let backend = if cfg!(target_os = "windows") {
746 "windows_files"
747 } else {
748 "xdg_files"
749 };
750 let available = locations.iter().any(|location| location.exists);
751 Ok(FileLocationListing {
752 availability: if available {
753 Availability::available(backend)
754 } else {
755 Availability::pending(backend, "No standard user file locations found.")
756 },
757 locations,
758 })
759 }
760
761 pub fn keychain_status() -> Result<KeychainStatus, IntegrationError> {
762 let check = car_secrets::SecretStore::new().availability();
763 Ok(KeychainStatus {
764 availability: if check.available {
765 Availability::available("keychain")
766 } else {
767 Availability::pending(
768 "keychain",
769 check
770 .reason
771 .unwrap_or_else(|| "OS keychain is unavailable.".to_string()),
772 )
773 },
774 })
775 }
776
777 fn pending(backend: &'static str) -> Availability {
778 Availability::pending(backend, "This Apple integration is only modeled on macOS.")
779 }
780
781 #[cfg(test)]
782 mod bookmark_tests {
783 use super::*;
784
785 const FIXTURE: &str = r#"{
788 "roots": {
789 "bookmark_bar": {
790 "type": "folder",
791 "children": [
792 { "type": "url", "name": "Rust", "url": "https://www.rust-lang.org/" },
793 { "type": "folder", "name": "Dev", "children": [
794 { "type": "url", "name": "GitHub", "url": "https://github.com/" }
795 ]}
796 ]
797 },
798 "other": {
799 "type": "folder",
800 "children": [
801 { "type": "url", "name": "", "url": "https://example.com/" }
802 ]
803 },
804 "synced": { "type": "folder", "children": [] }
805 }
806 }"#;
807
808 #[test]
809 fn parses_nested_bookmarks_across_roots() {
810 let mut out = Vec::new();
811 parse_chromium_bookmarks(FIXTURE, "chrome", &mut out, 100);
812 let urls: Vec<&str> = out.iter().filter_map(|b| b.url.as_deref()).collect();
813 assert_eq!(
814 urls,
815 [
816 "https://www.rust-lang.org/",
817 "https://github.com/",
818 "https://example.com/"
819 ]
820 );
821 let example = out
823 .iter()
824 .find(|b| b.url.as_deref() == Some("https://example.com/"))
825 .unwrap();
826 assert_eq!(example.title, "https://example.com/");
827 assert!(out.iter().all(|b| b.source == "chrome"));
828 }
829
830 #[test]
831 fn respects_the_limit() {
832 let mut out = Vec::new();
833 parse_chromium_bookmarks(FIXTURE, "edge", &mut out, 2);
834 assert_eq!(out.len(), 2);
835 }
836
837 #[test]
838 fn malformed_json_yields_nothing_and_does_not_panic() {
839 let mut out = Vec::new();
840 parse_chromium_bookmarks("}{ not json", "brave", &mut out, 10);
841 assert!(out.is_empty());
842 }
843 }
844}
845
846#[cfg(all(test, not(target_os = "macos")))]
847mod non_macos_tests {
848 use super::*;
849
850 #[test]
854 fn files_locations_is_real_off_macos() {
855 let listing = files_locations().expect("files_locations returns a listing");
856 let expected_backend = if cfg!(target_os = "windows") {
857 "windows_files"
858 } else {
859 "xdg_files"
860 };
861 assert_eq!(listing.availability.backend, expected_backend);
862
863 #[cfg(target_os = "windows")]
870 {
871 for id in ["desktop", "documents", "downloads", "onedrive"] {
872 assert!(
873 listing.locations.iter().any(|l| l.id == id),
874 "expected a {id} location on Windows, got {:?}",
875 listing.locations
876 );
877 }
878 }
879 }
880
881 #[test]
886 fn bookmarks_use_the_chromium_backend_off_macos() {
887 let listing = bookmarks_list(10).expect("bookmarks_list returns a listing");
888 assert_eq!(listing.availability.backend, "chromium_bookmarks");
889 if listing.availability.available {
890 assert!(
891 listing.bookmarks.iter().all(|b| b.url.is_some()),
892 "every collected bookmark should carry a URL"
893 );
894 } else {
895 assert!(listing.bookmarks.is_empty());
896 }
897 }
898
899 #[test]
904 fn notes_reminders_use_graph_backend_off_macos() {
905 if crate::msgraph::is_configured() {
906 return;
907 }
908 let na = notes_accounts().unwrap();
909 assert_eq!(na.availability.backend, "msgraph");
910 assert!(!na.availability.available && na.accounts.is_empty());
911
912 let nf = notes_find("x", 5).unwrap();
913 assert_eq!(nf.availability.backend, "msgraph");
914 assert!(nf.notes.is_empty());
915
916 let rl = reminders_lists().unwrap();
917 assert_eq!(rl.availability.backend, "msgraph");
918 assert!(rl.lists.is_empty());
919
920 let ri = reminders_items(5).unwrap();
921 assert_eq!(ri.availability.backend, "msgraph");
922 assert!(ri.reminders.is_empty());
923 }
924}