1use std::path::{Path, PathBuf};
9use std::str::FromStr;
10
11use directories::ProjectDirs;
12use serde::{Deserialize, Serialize};
13
14use crate::{Error, Result};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
23#[serde(rename_all = "lowercase")]
24pub enum StorageMode {
25 #[default]
27 Parquet,
28 DuckDB,
30}
31
32impl std::fmt::Display for StorageMode {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 match self {
35 StorageMode::Parquet => write!(f, "parquet"),
36 StorageMode::DuckDB => write!(f, "duckdb"),
37 }
38 }
39}
40
41impl FromStr for StorageMode {
42 type Err = Error;
43
44 fn from_str(s: &str) -> Result<Self> {
45 match s.to_lowercase().as_str() {
46 "parquet" => Ok(StorageMode::Parquet),
47 "duckdb" => Ok(StorageMode::DuckDB),
48 _ => Err(Error::Config(format!(
49 "Invalid storage mode '{}': expected 'parquet' or 'duckdb'",
50 s
51 ))),
52 }
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum RemoteType {
60 S3,
62 MotherDuck,
64 Postgres,
66 File,
68}
69
70impl std::fmt::Display for RemoteType {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 match self {
73 RemoteType::S3 => write!(f, "s3"),
74 RemoteType::MotherDuck => write!(f, "motherduck"),
75 RemoteType::Postgres => write!(f, "postgres"),
76 RemoteType::File => write!(f, "file"),
77 }
78 }
79}
80
81impl FromStr for RemoteType {
82 type Err = Error;
83
84 fn from_str(s: &str) -> Result<Self> {
85 match s.to_lowercase().as_str() {
86 "s3" | "gcs" => Ok(RemoteType::S3),
87 "motherduck" | "md" => Ok(RemoteType::MotherDuck),
88 "postgres" | "postgresql" | "pg" => Ok(RemoteType::Postgres),
89 "file" | "local" => Ok(RemoteType::File),
90 _ => Err(Error::Config(format!(
91 "Invalid remote type '{}': expected 's3', 'motherduck', 'postgres', or 'file'",
92 s
93 ))),
94 }
95 }
96}
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum RemoteMode {
102 #[default]
104 ReadWrite,
105 ReadOnly,
107}
108
109impl std::fmt::Display for RemoteMode {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 RemoteMode::ReadWrite => write!(f, "read_write"),
113 RemoteMode::ReadOnly => write!(f, "read_only"),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct RemoteConfig {
121 pub name: String,
123
124 #[serde(rename = "type")]
126 pub remote_type: RemoteType,
127
128 pub uri: String,
130
131 #[serde(default)]
133 pub mode: RemoteMode,
134
135 #[serde(default)]
137 pub credential_provider: Option<String>,
138
139 #[serde(default = "default_true")]
141 pub auto_attach: bool,
142}
143
144fn default_true() -> bool {
145 true
146}
147
148impl RemoteConfig {
149 pub fn new(name: impl Into<String>, remote_type: RemoteType, uri: impl Into<String>) -> Self {
151 Self {
152 name: name.into(),
153 remote_type,
154 uri: uri.into(),
155 mode: RemoteMode::default(),
156 credential_provider: None,
157 auto_attach: true,
158 }
159 }
160
161 pub fn read_only(mut self) -> Self {
163 self.mode = RemoteMode::ReadOnly;
164 self
165 }
166
167 pub fn schema_name(&self) -> String {
169 format!("remote_{}", self.name)
170 }
171
172 pub fn quoted_schema_name(&self) -> String {
174 format!("\"remote_{}\"", self.name)
175 }
176
177 pub fn attach_sql(&self) -> String {
179 let mode_clause = match self.mode {
180 RemoteMode::ReadOnly => " (READ_ONLY)",
181 RemoteMode::ReadWrite => "",
182 };
183
184 let type_clause = match self.remote_type {
185 RemoteType::Postgres => " (TYPE postgres)",
186 _ => "",
187 };
188
189 format!(
190 "ATTACH '{}' AS {}{}{}",
191 self.uri,
192 self.quoted_schema_name(),
193 type_clause,
194 mode_clause
195 )
196 }
197
198 pub fn blob_base_url(&self) -> Option<String> {
200 match self.remote_type {
201 RemoteType::S3 => {
202 if let Some(stripped) = self.uri.strip_suffix(".duckdb") {
205 Some(format!("{}/blobs", stripped))
206 } else {
207 Some(format!("{}/blobs", self.uri.trim_end_matches('/')))
208 }
209 }
210 _ => None,
211 }
212 }
213
214 pub fn data_dir(&self) -> Option<std::path::PathBuf> {
219 if self.remote_type != RemoteType::File {
220 return None;
221 }
222
223 let db_path = self.uri.strip_prefix("file://")?;
225 let db_path = std::path::Path::new(db_path);
226
227 db_path.parent().map(|p| p.join("data"))
229 }
230}
231
232#[derive(Debug, Clone, Default, Serialize, Deserialize)]
234pub struct SyncConfig {
235 #[serde(default)]
237 pub default_remote: Option<String>,
238
239 #[serde(default)]
241 pub push_on_compact: bool,
242
243 #[serde(default)]
245 pub push_on_archive: bool,
246
247 #[serde(default = "default_true")]
249 pub sync_invocations: bool,
250
251 #[serde(default = "default_true")]
253 pub sync_outputs: bool,
254
255 #[serde(default = "default_true")]
257 pub sync_events: bool,
258
259 #[serde(default)]
261 pub sync_blobs: bool,
262
263 #[serde(default = "default_blob_sync_min")]
265 pub blob_sync_min_bytes: usize,
266}
267
268fn default_blob_sync_min() -> usize {
269 1024 }
271
272#[derive(Debug, Clone, Serialize, Deserialize, Default)]
274pub struct HooksConfig {
275 #[serde(default = "default_ignore_patterns")]
278 pub ignore_patterns: Vec<String>,
279}
280
281fn default_ignore_patterns() -> Vec<String> {
282 vec![
283 "shq *".to_string(),
285 "shqr *".to_string(),
286 "blq *".to_string(),
287 "%*".to_string(),
289 "fg".to_string(),
291 "fg *".to_string(),
292 "bg".to_string(),
293 "bg *".to_string(),
294 "jobs".to_string(),
295 "jobs *".to_string(),
296 "exit".to_string(),
298 "logout".to_string(),
299 "clear".to_string(),
301 "history".to_string(),
302 "history *".to_string(),
303 ]
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
308pub struct Config {
309 pub bird_root: PathBuf,
311
312 #[serde(default = "default_client_id")]
314 pub client_id: String,
315
316 #[serde(default = "default_hot_days")]
318 pub hot_days: u32,
319
320 #[serde(default = "default_inline_threshold")]
322 pub inline_threshold: usize,
323
324 #[serde(default)]
326 pub auto_extract: bool,
327
328 #[serde(default)]
332 pub storage_mode: StorageMode,
333
334 #[serde(default)]
336 pub remotes: Vec<RemoteConfig>,
337
338 #[serde(default)]
340 pub sync: SyncConfig,
341
342 #[serde(default)]
344 pub hooks: HooksConfig,
345}
346
347fn default_client_id() -> String {
348 let username = std::env::var("USER")
350 .or_else(|_| std::env::var("USERNAME"))
351 .unwrap_or_else(|_| "unknown".to_string());
352 let hostname = gethostname::gethostname()
353 .to_string_lossy()
354 .to_string();
355 format!("{}@{}", username, hostname)
356}
357
358fn default_hot_days() -> u32 {
359 14
360}
361
362fn default_inline_threshold() -> usize {
363 4_096 }
365
366impl Config {
367 pub fn with_root(bird_root: impl Into<PathBuf>) -> Self {
369 Self {
370 bird_root: bird_root.into(),
371 client_id: default_client_id(),
372 hot_days: default_hot_days(),
373 inline_threshold: default_inline_threshold(),
374 auto_extract: false,
375 storage_mode: StorageMode::default(),
376 remotes: Vec::new(),
377 sync: SyncConfig::default(),
378 hooks: HooksConfig::default(),
379 }
380 }
381
382 pub fn with_duckdb_mode(bird_root: impl Into<PathBuf>) -> Self {
384 Self {
385 bird_root: bird_root.into(),
386 client_id: default_client_id(),
387 hot_days: default_hot_days(),
388 inline_threshold: default_inline_threshold(),
389 auto_extract: false,
390 storage_mode: StorageMode::DuckDB,
391 remotes: Vec::new(),
392 sync: SyncConfig::default(),
393 hooks: HooksConfig::default(),
394 }
395 }
396
397 pub fn default_location() -> Result<Self> {
399 let bird_root = resolve_bird_root()?;
400 Ok(Self::with_root(bird_root))
401 }
402
403 pub fn load() -> Result<Self> {
405 let bird_root = resolve_bird_root()?;
406 Self::load_from(&bird_root)
407 }
408
409 pub fn load_from(bird_root: &Path) -> Result<Self> {
411 let config_path = bird_root.join("config.toml");
412
413 if config_path.exists() {
414 let contents = std::fs::read_to_string(&config_path)?;
415 let mut config: Config = toml::from_str(&contents)
416 .map_err(|e| Error::Config(format!("Failed to parse config: {}", e)))?;
417 config.bird_root = bird_root.to_path_buf();
419 Ok(config)
420 } else {
421 Ok(Self::with_root(bird_root))
422 }
423 }
424
425 pub fn save(&self) -> Result<()> {
427 let config_path = self.bird_root.join("config.toml");
428 let contents = toml::to_string_pretty(self)
429 .map_err(|e| Error::Config(format!("Failed to serialize config: {}", e)))?;
430 std::fs::write(config_path, contents)?;
431 Ok(())
432 }
433
434 pub fn db_path(&self) -> PathBuf {
438 self.bird_root.join("db/bird.duckdb")
439 }
440
441 pub fn data_dir(&self) -> PathBuf {
443 self.bird_root.join("db/data")
444 }
445
446 pub fn recent_dir(&self) -> PathBuf {
448 self.data_dir().join("recent")
449 }
450
451 pub fn archive_dir(&self) -> PathBuf {
453 self.data_dir().join("archive")
454 }
455
456 pub fn invocations_dir_with_status(&self, status: &str, date: &chrono::NaiveDate) -> PathBuf {
460 self.recent_dir()
461 .join("invocations")
462 .join(format!("status={}", status))
463 .join(format!("date={}", date))
464 }
465
466 pub fn invocations_dir(&self, date: &chrono::NaiveDate) -> PathBuf {
470 self.invocations_dir_with_status("completed", date)
471 }
472
473 pub fn pending_dir(&self) -> PathBuf {
475 self.bird_root.join("db/pending")
476 }
477
478 pub fn outputs_dir(&self, date: &chrono::NaiveDate) -> PathBuf {
480 self.recent_dir()
481 .join("outputs")
482 .join(format!("date={}", date))
483 }
484
485 pub fn sessions_dir(&self, date: &chrono::NaiveDate) -> PathBuf {
487 self.recent_dir()
488 .join("sessions")
489 .join(format!("date={}", date))
490 }
491
492 pub fn sql_dir(&self) -> PathBuf {
494 self.bird_root.join("db/sql")
495 }
496
497 pub fn extensions_dir(&self) -> PathBuf {
499 self.bird_root.join("db/extensions")
500 }
501
502 pub fn blobs_dir(&self) -> PathBuf {
504 self.recent_dir().join("blobs/content")
505 }
506
507 pub fn blob_path(&self, hash: &str, cmd_hint: &str) -> PathBuf {
509 let prefix = &hash[..2.min(hash.len())];
510 let sanitized_cmd = sanitize_for_filename(cmd_hint);
511 self.blobs_dir()
512 .join(prefix)
513 .join(format!("{}--{}.bin", hash, sanitized_cmd))
514 }
515
516 pub fn event_formats_path(&self) -> PathBuf {
518 self.bird_root.join("event-formats.toml")
519 }
520
521 pub fn format_hints_path(&self) -> PathBuf {
523 self.bird_root.join("format-hints.toml")
524 }
525
526 pub fn events_dir(&self, date: &chrono::NaiveDate) -> PathBuf {
528 self.recent_dir()
529 .join("events")
530 .join(format!("date={}", date))
531 }
532
533 pub fn get_remote(&self, name: &str) -> Option<&RemoteConfig> {
537 self.remotes.iter().find(|r| r.name == name)
538 }
539
540 pub fn add_remote(&mut self, remote: RemoteConfig) {
542 self.remotes.retain(|r| r.name != remote.name);
544 self.remotes.push(remote);
545 }
546
547 pub fn remove_remote(&mut self, name: &str) -> bool {
549 let len_before = self.remotes.len();
550 self.remotes.retain(|r| r.name != name);
551 self.remotes.len() < len_before
552 }
553
554 pub fn blob_roots(&self) -> Vec<String> {
557 let mut roots = vec![self.blobs_dir().to_string_lossy().to_string()];
558
559 for remote in &self.remotes {
560 if let Some(blob_url) = remote.blob_base_url() {
561 roots.push(blob_url);
562 }
563 }
564
565 roots
566 }
567
568 pub fn auto_attach_remotes(&self) -> Vec<&RemoteConfig> {
570 self.remotes.iter().filter(|r| r.auto_attach).collect()
571 }
572}
573
574fn sanitize_for_filename(s: &str) -> String {
576 s.chars()
577 .map(|c| match c {
578 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
579 ' ' => '-',
580 c if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' => c,
581 _ => '_',
582 })
583 .take(32) .collect()
585}
586
587fn resolve_bird_root() -> Result<PathBuf> {
589 if let Ok(path) = std::env::var("BIRD_ROOT") {
591 return Ok(PathBuf::from(path));
592 }
593
594 if let Some(proj_dirs) = ProjectDirs::from("", "", "bird") {
596 return Ok(proj_dirs.data_dir().to_path_buf());
597 }
598
599 let home = std::env::var("HOME")
601 .map_err(|_| Error::Config("Could not determine home directory".to_string()))?;
602 Ok(PathBuf::from(home).join(".local/share/bird"))
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608 use tempfile::TempDir;
609
610 #[test]
611 fn test_config_with_root() {
612 let config = Config::with_root("/tmp/test-bird");
613 assert_eq!(config.bird_root, PathBuf::from("/tmp/test-bird"));
614 assert_eq!(config.hot_days, 14);
615 assert_eq!(config.inline_threshold, 4_096);
616 }
617
618 #[test]
619 fn test_blob_path() {
620 let config = Config::with_root("/tmp/test-bird");
621 let path = config.blob_path("abcdef123456", "make test");
622 assert_eq!(
623 path,
624 PathBuf::from("/tmp/test-bird/db/data/recent/blobs/content/ab/abcdef123456--make-test.bin")
625 );
626 }
627
628 #[test]
629 fn test_config_paths() {
630 let config = Config::with_root("/tmp/test-bird");
631 assert_eq!(config.db_path(), PathBuf::from("/tmp/test-bird/db/bird.duckdb"));
632 assert_eq!(config.recent_dir(), PathBuf::from("/tmp/test-bird/db/data/recent"));
633 }
634
635 #[test]
636 fn test_config_save_load() {
637 let tmp = TempDir::new().unwrap();
638 let bird_root = tmp.path().to_path_buf();
639
640 std::fs::create_dir_all(&bird_root).unwrap();
642
643 let config = Config::with_root(&bird_root);
644 config.save().unwrap();
645
646 let loaded = Config::load_from(&bird_root).unwrap();
647 assert_eq!(loaded.hot_days, config.hot_days);
648 assert_eq!(loaded.inline_threshold, config.inline_threshold);
649 }
650}