Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,877 changes: 0 additions & 1,877 deletions src/commands/setup.rs

This file was deleted.

217 changes: 217 additions & 0 deletions src/commands/setup/detect.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! Environment and config detection helpers for the setup wizard.
//!
//! Reads existing `grob.toml` files, environment variables, and preset
//! definitions to pre-fill the wizard with sensible defaults.

use std::path::Path;

use super::types::{Compliance, DEPRECATED_KEYS, KNOWN_SECTIONS, PROVIDER_AUTH};

/// Returns the `(supports_oauth, oauth_id, env_var)` tuple for a provider.
pub(in crate::commands::setup) fn auth_for(
name: &str,
) -> Option<(bool, &'static str, &'static str)> {
PROVIDER_AUTH
.iter()
.find(|(n, ..)| *n == name)
.map(|(_, oauth, id, env)| (*oauth, *id, *env))
}

/// Detects API keys present in the environment for known providers.
pub(in crate::commands::setup) fn discover_credentials() -> Vec<(&'static str, &'static str)> {
PROVIDER_AUTH
.iter()
.filter_map(|(name, _, _, env_var)| {
if std::env::var(env_var).is_ok() {
Some((*name, *env_var))
} else {
None
}
})
.collect()
}

/// Checks the existing config for unknown or deprecated top-level keys.
pub(in crate::commands::setup) fn check_schema_drift(config_path: &Path) {
let content = match std::fs::read_to_string(config_path) {
Ok(c) => c,
Err(_) => return,
};
let table: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(_) => return,
};
let Some(top) = table.as_table() else {
return;
};

let mut drift_found = false;

for (key, hint) in DEPRECATED_KEYS {
if top.contains_key(*key) {
if !drift_found {
println!();
println!(" Schema drift detected in existing config:");
drift_found = true;
}
println!(" [deprecated] '{}': {}", key, hint);
}
}

for key in top.keys() {
if !KNOWN_SECTIONS.contains(&key.as_str())
&& !DEPRECATED_KEYS.iter().any(|(k, _)| *k == key.as_str())
{
if !drift_found {
println!();
println!(" Schema drift detected in existing config:");
drift_found = true;
}
println!(
" [unknown] '{}': not a recognized section. Remove it or check for typos.",
key
);
}
}
}

/// Opens a URL in the default browser (best-effort, no error on failure).
#[allow(dead_code)]
pub(in crate::commands::setup) fn open_browser(url: &str) {
#[cfg(target_os = "macos")]
{
let _ = std::process::Command::new("open").arg(url).spawn();
}
#[cfg(target_os = "linux")]
{
let _ = std::process::Command::new("xdg-open").arg(url).spawn();
}
#[cfg(target_os = "windows")]
{
let _ = std::process::Command::new("cmd")
.args(["/C", "start", url])
.spawn();
}
}

/// Reads an existing grob.toml and extracts pre-fill defaults.
pub(in crate::commands::setup) fn prefill_from_config(
config_path: &Path,
) -> Option<(Vec<String>, bool, Option<i64>)> {
let content = std::fs::read_to_string(config_path).ok()?;
let config: toml::Value = toml::from_str(&content).ok()?;

let providers: Vec<String> = config
.get("providers")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| p.get("name").and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default();

let has_fallback = providers.iter().any(|p| p == "openrouter" || p == "gemini");

let budget = config
.get("budget")
.and_then(|b| b.get("monthly_limit_usd"))
.and_then(|v| v.as_integer());

Some((providers, has_fallback, budget))
}

/// Reads GROB_SETUP_* environment variables for non-interactive setup.
pub(in crate::commands::setup) fn env_overrides() -> (Option<String>, Option<i64>, Option<String>) {
let provider = std::env::var("GROB_SETUP_PROVIDER").ok();
let budget = std::env::var("GROB_SETUP_BUDGET")
.ok()
.and_then(|v| v.parse::<i64>().ok());
let compliance = std::env::var("GROB_SETUP_COMPLIANCE").ok();
(provider, budget, compliance)
}

/// Maps a compliance string to its enum variant.
pub(in crate::commands::setup) fn parse_compliance(s: &str) -> Compliance {
match s.to_lowercase().as_str() {
"dlp" => Compliance::Dlp,
"gdpr" | "eu-gdpr" | "eu" => Compliance::EuGdpr,
"enterprise" => Compliance::Enterprise,
"local" | "local-only" | "ollama" => Compliance::LocalOnly,
_ => Compliance::Standard,
}
}

/// Returns the enabled provider names declared by a preset's TOML.
pub(in crate::commands::setup) fn providers_from_preset(name: &str) -> Vec<String> {
let content = match crate::preset::preset_content(name) {
Ok(c) => c,
Err(_) => return vec![],
};
let val: toml::Value = match toml::from_str(&content) {
Ok(v) => v,
Err(_) => return vec![],
};
val.get("providers")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter(|p| p.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.filter_map(|p| p.get("name").and_then(|n| n.as_str()).map(String::from))
.collect()
})
.unwrap_or_default()
}

#[cfg(test)]
mod tests {
use super::*;

/// W-2-polish : `parse_compliance` maps known strings to the right variant.
#[test]
fn test_parse_compliance_variants() {
assert!(matches!(parse_compliance("dlp"), Compliance::Dlp));
assert!(matches!(parse_compliance("DLP"), Compliance::Dlp));
assert!(matches!(parse_compliance("gdpr"), Compliance::EuGdpr));
assert!(matches!(parse_compliance("eu-gdpr"), Compliance::EuGdpr));
assert!(matches!(
parse_compliance("enterprise"),
Compliance::Enterprise
));
assert!(matches!(
parse_compliance("local-only"),
Compliance::LocalOnly
));
assert!(matches!(parse_compliance("ollama"), Compliance::LocalOnly));
assert!(matches!(parse_compliance("standard"), Compliance::Standard));
assert!(matches!(parse_compliance("unknown"), Compliance::Standard));
}

/// W-2-polish : `check_schema_drift` detects deprecated keys.
#[test]
fn test_schema_drift_detects_deprecated() {
// Just test the constant is well-formed (the function prints to stdout).
assert!(DEPRECATED_KEYS.len() >= 2);
assert!(KNOWN_SECTIONS.contains(&"server"));
assert!(KNOWN_SECTIONS.contains(&"providers"));
assert!(KNOWN_SECTIONS.contains(&"budget"));
}

/// W-2-polish : `discover_credentials` returns empty when no env vars set.
#[test]
fn test_discover_credentials_empty_when_no_env() {
// In test env, none of the provider env vars should be set.
// This test validates the function does not panic and returns
// a Vec; we can't assert emptiness because CI may have some vars set.
let _result = discover_credentials();
}

/// W-2-polish : `env_overrides` reads GROB_SETUP_* variables.
#[test]
fn test_env_overrides_returns_none_when_unset() {
let (provider, budget, compliance) = env_overrides();
// Unless explicitly set in the test environment, these should be None.
// We just check the function doesn't panic.
let _ = (provider, budget, compliance);
}
}
128 changes: 128 additions & 0 deletions src/commands/setup/input.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Low-level TTY input helpers for the setup wizard.
//!
//! Shared between all screens and the top-level orchestrator. Keeps
//! `mod.rs` focused on flow rather than stdin/stdout plumbing.

use std::io::{self, Write};

/// Reads one trimmed line from stdin, returning an empty string on EOF.
pub(in crate::commands::setup) fn read_line() -> String {
let mut s = String::new();
io::stdin().read_line(&mut s).ok();
s.trim().to_string()
}

/// Prompts for a number in `1..=max`, looping until the input is valid.
pub(in crate::commands::setup) fn prompt_choice(max: usize) -> usize {
loop {
print!(" > ");
io::stdout().flush().ok();
if let Ok(n) = read_line().parse::<usize>() {
if n >= 1 && n <= max {
return n;
}
}
println!(" Enter a number between 1 and {}", max);
}
}

/// Prompts for a comma/space-separated list of 1-based indices or `all`.
///
/// Returns zero-based indices filtered to `0..max`.
pub(in crate::commands::setup) fn prompt_multi(max: usize) -> Vec<usize> {
loop {
print!(" > ");
io::stdout().flush().ok();
let input = read_line();
if input.eq_ignore_ascii_case("all") {
return (0..max).collect();
}
let v: Vec<usize> = input
.split(|c: char| c == ',' || c.is_whitespace())
.filter_map(|s| s.trim().parse::<usize>().ok())
.filter(|&n| n >= 1 && n <= max)
.map(|n| n - 1)
.collect();
if !v.is_empty() {
return v;
}
println!(" Enter numbers separated by commas (e.g. 1,3) or 'all'");
}
}

/// Reads an API key and optionally validates it against the provider.
///
/// Returns `None` when the env var is already set (user defers to env), when
/// the user enters an empty key, or when validation fails and the user
/// declines to override.
pub(in crate::commands::setup) fn prompt_key_for_provider(
env_var: &str,
provider_name: Option<&str>,
) -> Option<String> {
if std::env::var(env_var).is_ok() {
println!(" ${} already set in environment", env_var);
return None;
}
print!(" API key: ");
io::stdout().flush().ok();
let key = read_line();
if key.is_empty() {
println!(" Skipped");
return None;
}

// Best-effort validation when the provider is known.
if let Some(name) = provider_name {
let rt = tokio::runtime::Handle::try_current();
let valid = match rt {
Ok(handle) => tokio::task::block_in_place(|| {
handle.block_on(crate::commands::credential_check::validate_api_key(
name, &key,
))
}),
Err(_) => {
// No runtime available — skip validation.
true
}
};
if !valid {
println!(
" Warning: {} returned auth error. The key may be expired, revoked, or incorrect.",
name
);
println!(" Check your key at the provider dashboard, or try a different one.");
println!(" Continue anyway? [y/N]");
if !confirm(" > ") {
println!(
" Key rejected. Run: grob connect {} (to retry later)",
name
);
return None;
}
}
}

println!(" Accepted (stored as ${} reference)", env_var);
Some(key)
}

/// Prompts for a URL, requiring an `http://` or `https://` scheme.
pub(in crate::commands::setup) fn prompt_url(label: &str) -> String {
loop {
print!(" {}: ", label);
io::stdout().flush().ok();
let url = read_line();
if url.starts_with("http://") || url.starts_with("https://") {
return url;
}
println!(" URL must start with http:// or https://");
}
}

/// Returns `true` when the user answers `y`/`yes` (case-insensitive).
pub(in crate::commands::setup) fn confirm(prompt: &str) -> bool {
print!("{}", prompt);
io::stdout().flush().ok();
let a = read_line();
a.eq_ignore_ascii_case("y") || a.eq_ignore_ascii_case("yes")
}
Loading
Loading