use anyhow::Result;
use console::Style;
use dialoguer::Select;
use toml_edit::{value, DocumentMut};
pub(super) fn patch_deployment_mode(doc: &mut DocumentMut, mode: &str) {
if doc.get("deployment_mode").is_some() {
return;
}
doc.insert("deployment_mode", value(mode));
if let Some(mut key) = doc.key_mut("deployment_mode") {
key.leaf_decor_mut().set_prefix(
"\n# --- Deployment Mode ---\n\
# Controls which content source types are available.\n\
# \"desktop\" (default), \"self_host\", or \"cloud\"\n",
);
}
}
pub(super) fn patch_connectors(doc: &mut DocumentMut, client_id: &str, client_secret: &str) {
if doc.get("connectors").is_some() {
return;
}
let mut gd_table = toml_edit::Table::new();
gd_table.insert("client_id", value(client_id));
gd_table.insert("client_secret", value(client_secret));
gd_table.insert(
"redirect_uri",
value("http://localhost:3001/api/connectors/google-drive/callback"),
);
let mut connectors = toml_edit::Table::new();
connectors.insert("google_drive", toml_edit::Item::Table(gd_table));
connectors.decor_mut().set_prefix(
"\n# --- Connectors ---\n\
# OAuth application credentials for remote source linking.\n\
# Get these from Google Cloud Console > APIs & Services > Credentials.\n",
);
doc.insert("connectors", toml_edit::Item::Table(connectors));
}
pub(super) fn patch_content_sources(doc: &mut DocumentMut) {
if doc.get("content_sources").is_some() {
return;
}
let table = toml_edit::Table::new();
let mut item = toml_edit::Item::Table(table);
if let Some(t) = item.as_table_mut() {
t.decor_mut().set_prefix(
"\n# --- Content Sources ---\n\
# Configure via the dashboard: Settings > Content Sources.\n\
# Desktop: point to your Obsidian vault or notes folder.\n\
# Self-hosted/Cloud: connect Google Drive.\n",
);
}
doc.insert("content_sources", item);
}
pub(super) fn print_legacy_sa_key_notice(content: &str) -> bool {
let table: toml::Value = match content.parse() {
Ok(v) => v,
Err(_) => return false,
};
let root = match table.as_table() {
Some(t) => t,
None => return false,
};
let sources = match root
.get("content_sources")
.and_then(|cs| cs.as_table())
.and_then(|cs| cs.get("sources"))
.and_then(|s| s.as_array())
{
Some(arr) => arr,
None => return false,
};
let has_legacy = sources.iter().any(|src| {
let has_sa = src
.get("service_account_key")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty());
let has_conn = src
.get("connection_id")
.and_then(|v| v.as_integer())
.is_some();
has_sa && !has_conn
});
if has_legacy {
let dim = Style::new().dim();
let tip = Style::new().cyan().bold();
eprintln!();
eprintln!(
"{}",
tip.apply_to("Tip: Your Google Drive source uses a service-account key (legacy).")
);
eprintln!(
"{}",
dim.apply_to(
" Consider switching to a linked account for simpler setup.\n \
Open the dashboard > Settings > Content Sources > Connect Google Drive."
)
);
eprintln!();
}
has_legacy
}
pub(super) fn prompt_deployment_mode() -> Result<String> {
let modes = &["desktop", "self_host", "cloud"];
let descriptions = &[
"Desktop -- Tauri native app with local file picker",
"Self-Host -- Docker/VPS with browser dashboard",
"Cloud -- Managed service (no local filesystem access)",
];
let selection = Select::new()
.with_prompt("How do you run Tuitbot?")
.items(descriptions)
.default(0)
.interact()?;
Ok(modes[selection].to_string())
}
pub(super) fn prompt_connectors() -> Result<Option<(String, String)>> {
let dim = Style::new().dim();
eprintln!(
"{}",
dim.apply_to(
" If you use Google Drive for content, enter your GCP OAuth credentials.\n \
You can skip this and configure via environment variables or the dashboard later."
)
);
let skip = dialoguer::Confirm::new()
.with_prompt("Skip Google Drive connector setup?")
.default(true)
.interact()?;
if skip {
return Ok(None);
}
let client_id: String = dialoguer::Input::new()
.with_prompt("GCP OAuth Client ID")
.interact_text()?;
let client_secret: String = dialoguer::Input::new()
.with_prompt("GCP OAuth Client Secret")
.interact_text()?;
Ok(Some((client_id, client_secret)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn patch_deployment_mode_inserts_when_missing() {
let mut doc: DocumentMut = "[business]\nname = \"test\"\n".parse().unwrap();
patch_deployment_mode(&mut doc, "desktop");
assert_eq!(
doc.get("deployment_mode").unwrap().as_str().unwrap(),
"desktop"
);
}
#[test]
fn patch_deployment_mode_noop_when_present() {
let mut doc: DocumentMut = "deployment_mode = \"cloud\"\n".parse().unwrap();
patch_deployment_mode(&mut doc, "desktop");
assert_eq!(
doc.get("deployment_mode").unwrap().as_str().unwrap(),
"cloud",
"should not overwrite existing"
);
}
#[test]
fn patch_connectors_inserts_when_missing() {
let mut doc: DocumentMut = "[business]\nname = \"test\"\n".parse().unwrap();
patch_connectors(&mut doc, "my-client-id", "my-secret");
let conn = doc.get("connectors").unwrap();
let gd = conn.get("google_drive").unwrap();
assert_eq!(
gd.get("client_id").unwrap().as_str().unwrap(),
"my-client-id"
);
assert_eq!(
gd.get("client_secret").unwrap().as_str().unwrap(),
"my-secret"
);
}
#[test]
fn patch_connectors_noop_when_present() {
let mut doc: DocumentMut = "[connectors]\nother = \"value\"\n".parse().unwrap();
patch_connectors(&mut doc, "id", "secret");
assert!(doc["connectors"].get("google_drive").is_none());
}
#[test]
fn patch_content_sources_inserts_when_missing() {
let mut doc: DocumentMut = "[business]\nname = \"test\"\n".parse().unwrap();
patch_content_sources(&mut doc);
assert!(doc.get("content_sources").is_some());
}
#[test]
fn patch_content_sources_noop_when_present() {
let mut doc: DocumentMut = "[content_sources]\nsome_key = \"val\"\n".parse().unwrap();
patch_content_sources(&mut doc);
assert!(doc["content_sources"].get("some_key").is_some());
}
#[test]
fn has_legacy_sa_key_detects_legacy() {
let toml = r#"
[content_sources]
[[content_sources.sources]]
service_account_key = "key123"
source_type = "google_drive"
"#;
assert!(has_legacy_sa_key(toml));
}
#[test]
fn has_legacy_sa_key_ignores_with_connection_id() {
let toml = r#"
[content_sources]
[[content_sources.sources]]
service_account_key = "key123"
connection_id = 1
source_type = "google_drive"
"#;
assert!(!has_legacy_sa_key(toml));
}
#[test]
fn has_legacy_sa_key_returns_false_for_no_sources() {
let toml = "[business]\nname = \"test\"\n";
assert!(!has_legacy_sa_key(toml));
}
#[test]
fn has_legacy_sa_key_returns_false_for_empty_sa() {
let toml = r#"
[content_sources]
[[content_sources.sources]]
service_account_key = ""
source_type = "google_drive"
"#;
assert!(!has_legacy_sa_key(toml));
}
#[test]
fn print_legacy_sa_key_notice_returns_false_for_invalid_toml() {
assert!(!print_legacy_sa_key_notice("not valid toml {{{"));
}
#[test]
fn print_legacy_sa_key_notice_returns_false_for_no_sources() {
assert!(!print_legacy_sa_key_notice("[business]\nname = \"test\"\n"));
}
}
#[cfg(test)]
pub(super) fn has_legacy_sa_key(content: &str) -> bool {
let table: toml::Value = match content.parse() {
Ok(v) => v,
Err(_) => return false,
};
let root = match table.as_table() {
Some(t) => t,
None => return false,
};
let sources = match root
.get("content_sources")
.and_then(|cs| cs.as_table())
.and_then(|cs| cs.get("sources"))
.and_then(|s| s.as_array())
{
Some(arr) => arr,
None => return false,
};
sources.iter().any(|src| {
let has_sa = src
.get("service_account_key")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty());
let has_conn = src
.get("connection_id")
.and_then(|v| v.as_integer())
.is_some();
has_sa && !has_conn
})
}