use std::collections::VecDeque; use crate::app::harness::Verdict; use sha2::Digest; use crate::app::mode::ModeKind; use crate::app::review::{should_trigger_review, trigger_review}; use crate::app::state::rest::{AppStateRest, ChatMessageDisplay}; use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{Origin, Overlay, Toast, ToastKind}; use crate::dto::chat::message::{ChatMessage, Role}; const MAX_TOOL_ONLY_TURNS: usize = 6; const MAX_AGENT_STEPS: usize = 40; #[derive(Debug, Clone)] pub enum Action { ForceQuit, SwitchMode(ModeKind), SubmitInput(String), InsertChar(char), DeleteChar, DeleteCharRight, CursorLeft, CursorRight, HistoryUp, HistoryDown, ScrollUp, ScrollDown, OpenOverlay(Overlay), CloseOverlay, ToggleYoloArm, SystemNote { kind: String, message: String, }, QuitConfirm, Resize(u16, u16), Tick, LessonExport { path: String, }, LessonImport { path: String, }, LessonAccept { name: String, }, LessonReject { name: String, }, StartOAuth { provider: String, }, OpenEditor { path: String, }, McpAdd { name: String, command: String, }, ModelList, AbortTurn, } pub fn apply_action(state: &mut AppStateRest, action: Action) { match action { Action::ForceQuit => { save_current_session(state); state.quit = true; } Action::SwitchMode(mode) => { state.misc.overlay = match mode { ModeKind::Chat | ModeKind::Agents | ModeKind::Bash | ModeKind::Workflow => Overlay::None, ModeKind::Help => Overlay::Help, ModeKind::Settings => Overlay::Settings, ModeKind::QuitConfirm => Overlay::QuitConfirm, ModeKind::KeyInput => Overlay::KeyInput, ModeKind::Editor => Overlay::Editor, ModeKind::Effort => Overlay::Effort, ModeKind::Mcp => Overlay::Mcp, ModeKind::Security => Overlay::Security, ModeKind::Todo => Overlay::Todo, ModeKind::Rewind => Overlay::Rewind, ModeKind::Loading => Overlay::Loading, }; state.dirty = true; } Action::SubmitInput(text) => { state.input.submit(); let text = text.trim().to_string(); if text.is_empty() { state.dirty = true; return; } state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone())); if let Some(ref mut rt) = state.session_runtime { rt.push_message(ChatMessage::user(text)); } state.misc.thinking = true; spawn_turn(state); state.dirty = true; } Action::InsertChar(c) => { state.input.insert(c); state.dirty = true; } Action::DeleteChar => { state.input.delete_left(); state.dirty = true; } Action::DeleteCharRight => { state.input.delete_right(); state.dirty = true; } Action::CursorLeft => { state.input.char_left(); } Action::CursorRight => { state.input.char_right(); } Action::HistoryUp => { state.input.history_up(); state.dirty = true; } Action::HistoryDown => { state.input.history_down(); state.dirty = true; } Action::ScrollUp => { state.scroll.scroll_up(5); state.dirty = true; } Action::ScrollDown => { state.scroll.scroll_down(5); state.dirty = true; } Action::OpenOverlay(overlay) => { state.misc.overlay = overlay; state.dirty = true; } Action::OpenEditor { path } => { let resolved = crate::tool::resolve_path(&state.workspace_roots, &path); match resolved { Ok(abs_path) => { let content = std::fs::read_to_string(&abs_path) .unwrap_or_default(); let lines: Vec = content.lines().map(|l| l.to_string()).collect(); let ed = crate::app::mode::editor::EditorState::open( abs_path.to_string_lossy().to_string(), Some(lines), ); state.misc.editor = Some(ed); state.misc.overlay = Overlay::Editor; state.push_toast(Toast::new(ToastKind::Info, format!("Editing {}", path))); } Err(e) => { state.push_toast(Toast::new(ToastKind::Error, format!("Failed to open {}: {}", path, e))); } } state.dirty = true; } Action::McpAdd { name, command } => { let extra_args: Vec = command.split_whitespace().map(|s| s.to_string()).collect(); let cmd = extra_args.first().cloned().unwrap_or_default(); let args: Vec = extra_args.into_iter().skip(1).collect(); match state.mcp_manager.connect_stdio(&name, &cmd, &args) { Ok(_) => { let tool_count = state.mcp_manager.servers.last() .map(|s| s.tools.len()) .unwrap_or(0); state.push_toast(Toast::new(ToastKind::Success, format!("Connected MCP server '{}' ({} tools)", name, tool_count))); state.dirty = true; } Err(e) => { state.push_toast(Toast::new(ToastKind::Error, format!("MCP connect failed: {}", e))); } } } Action::ModelList => { state.misc.selected_index = 0; state.misc.overlay = Overlay::ModelSelector; state.dirty = true; } Action::CloseOverlay => { // If the overlay is the Editor, dismiss it properly first if state.misc.overlay == Overlay::Editor { crate::app::mode::editor::handle_editor_dismiss(state); } state.misc.overlay = Overlay::None; state.dirty = true; } Action::ToggleYoloArm => { state.misc.yolo_armed = !state.misc.yolo_armed; state.dirty = true; } Action::SystemNote { kind: _kind, message } => { let toast = crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Info, message, ); state.push_toast(toast); } Action::QuitConfirm => { state.misc.overlay = Overlay::QuitConfirm; state.dirty = true; } Action::Resize(w, _h) => { state.scroll.set_max_visible(w as usize); state.dirty = true; } Action::LessonExport { path } => { let dest = std::path::Path::new(&path); if let Some(parent) = dest.parent() { let _ = std::fs::create_dir_all(parent); } match crate::model::memory::export_lessons(&state.memory_dir, dest) { Ok(_) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("lessons exported to {}", path), )); } Err(e) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, format!("export failed: {}", e), )); } } state.dirty = true; } Action::LessonImport { path } => { let src = std::path::Path::new(&path); match crate::model::memory::import_lessons(&state.memory_dir, src) { Ok(count) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Success, format!("imported {} lessons from {}", count, path), )); } Err(e) => { state.push_toast(crate::app::state::types::Toast::new( crate::app::state::types::ToastKind::Error, format!("import failed: {}", e), )); } } state.dirty = true; } Action::StartOAuth { provider } => { let turn_events = state.turn_events.clone(); let provider_clone = provider.clone(); std::thread::spawn(move || { let result = run_oauth_flow(&provider_clone); let message = match result { Ok(msg) => msg, Err(e) => format!("OAuth login failed: {}", e), }; if let Ok(mut q) = turn_events.lock() { q.push_back(TurnEvent::SystemNote { kind: "oauth".to_string(), message, }); } }); let toast = Toast::new(ToastKind::Info, format!("Opening browser for {} login...", provider)); state.push_toast(toast); state.dirty = true; } Action::Tick => { let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); crate::app::review::maybe_run_staleness_sweep(state); if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir); } let events: Vec = { if let Ok(mut q) = state.turn_events.lock() { q.drain(..).collect() } else { Vec::new() } }; let mut turn_finished = false; for event in events { match event { TurnEvent::AssistantMessage(msg) => { state.misc.thinking = false; state.misc.api_connected = true; let display_content = msg.content.clone().unwrap_or_default(); if !display_content.is_empty() { state.push_transcript(ChatMessageDisplay::new(Role::Assistant, display_content)); } if let Some(ref mut rt) = state.session_runtime { rt.push_message(msg); } } TurnEvent::ToolResult { tool_call_id, tool_name, output, is_error, path } => { state.misc.thinking = false; let display_path = path.unwrap_or_default(); let display = if tool_name == "read" { let line_count = output.lines().count(); if !display_path.is_empty() { format!("read: {} ({} lines)", display_path, line_count) } else { format!("read: {} line(s)", line_count) } } else { format!("{}: {}", tool_name, output) }; state.push_transcript(ChatMessageDisplay::new( Role::Tool, display, )); if let Some(ref mut rt) = state.session_runtime { rt.push_message(ChatMessage::tool_result(tool_call_id.clone(), output.clone())); rt.tool_call_results.push(crate::app::state::runtime::ToolCallResult { tool_call_id, tool_name, output, is_error, duration_ms: 0, }); } } TurnEvent::SystemNote { kind, message } => { if kind == "edits" { if let Some(ref mut rt) = state.session_runtime { if let Ok(count) = message.parse::() { rt.edit_count += count; } } if should_trigger_review(state, Origin::Main) { let _ = trigger_review(state); } } else if kind == "review" { let lessons_found = if message.contains("lesson") || message.contains("Lesson") { message.rsplit(' ').next().and_then(|w| { w.trim_end_matches(')').trim_end_matches('s') .split('(').next_back() .and_then(|n| n.parse::().ok()) }).unwrap_or(0) } else { 0 }; if let Some(ref mut rt) = state.session_runtime { if lessons_found > 0 { rt.consecutive_empty_reviews = 0; rt.lesson_count += lessons_found; } else { rt.consecutive_empty_reviews += 1; } } state.push_toast(Toast::new(ToastKind::Info, message)); } else { state.push_toast(Toast::new(ToastKind::Info, message)); } } TurnEvent::StreamStart => { state.misc.thinking = false; state.misc.api_connected = true; state.push_transcript(ChatMessageDisplay::new(Role::Assistant, String::new())); } TurnEvent::StreamToken(delta) => { if let Some(last) = state.transcript_cache.messages.last_mut() { if last.role == Role::Assistant { last.content.push_str(&delta); state.transcript_cache.dirty = true; } } } TurnEvent::StreamDone(msg) => { state.misc.thinking = false; if let Some(ref mut rt) = state.session_runtime { rt.push_message(msg); } } TurnEvent::Usage { tokens_in, tokens_out } => { if let Some(ref mut rt) = state.session_runtime { rt.usage.tokens_in += tokens_in; rt.usage.tokens_out += tokens_out; rt.usage.api_calls += 1; } } TurnEvent::Error(msg) => { state.misc.api_connected = false; let long_toast = Toast { kind: ToastKind::Error, message: msg.clone(), created_at: chrono::Utc::now().timestamp_millis(), lifetime_ms: 15000, }; state.push_toast(long_toast); state.push_transcript(ChatMessageDisplay::new( crate::dto::chat::message::Role::System, format!("Error: {}", msg), )); turn_finished = true; } TurnEvent::Done => { state.misc.thinking = false; turn_finished = true; } } } if turn_finished { maybe_trigger_review(state); } if turn_finished || state.dirty { state.dirty = true; } } Action::AbortTurn => { state.abort_flag.store(true, std::sync::atomic::Ordering::SeqCst); state.push_toast(Toast::new(ToastKind::Warning, "Aborting generation...".to_string())); } Action::LessonAccept { name } => { if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( &rt.session_dir, &state.memory_dir, &name, true, ); } state.push_toast(Toast::new(ToastKind::Success, format!("accepted lesson: {}", name))); state.dirty = true; } Action::LessonReject { name } => { if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( &rt.session_dir, &state.memory_dir, &name, false, ); } state.push_toast(Toast::new(ToastKind::Info, format!("rejected lesson: {}", name))); state.dirty = true; } } } fn spawn_turn(state: &AppStateRest) { let in_flight = if let Ok(guard) = state.turn_in_flight.lock() { *guard } else { return; }; if in_flight { return; } let messages = state .session_runtime .as_ref() .map(|rt| rt.messages.clone()) .unwrap_or_default(); if messages.is_empty() { return; } let mut api_key = state.settings.api_keys.get(&state.settings.provider).cloned().unwrap_or_default(); let model = state.settings.model.clone(); let base_url = state.app_config.providers.get(&state.settings.provider) .map(|p| p.api_base.clone()); if api_key.is_empty() { if let Some(provider_cfg) = state.app_config.providers.get(&state.settings.provider) { api_key = provider_cfg.api_key_env.as_ref() .and_then(|env| std::env::var(env).ok()) .or_else(|| provider_cfg.default_api_key.clone()) .unwrap_or_default(); } } if api_key.is_empty() { api_key = crate::service::provider::DEFAULT_API_KEY.to_string(); } let (temperature, max_tokens) = crate::app::mode::effort::generation_params( state.misc.effort_level, state.settings.max_tokens, ); let mut tools = crate::tool::all_tools(); tools.extend(state.mcp_manager.as_tools()); let tool_defs = crate::tool::tool_defs(&tools); let ctx = state.tool_ctx(); let edit_session_dir = state.session_dir.clone(); let session_id = state.session_id.clone(); let turn_events = state.turn_events.clone(); let in_flight_flag = state.turn_in_flight.clone(); let workspace_roots: Vec = ctx.workspaces.clone(); let abort_flag = state.abort_flag.clone(); abort_flag.store(false, std::sync::atomic::Ordering::SeqCst); *in_flight_flag.lock().unwrap() = true; let events_q = turn_events.clone(); std::thread::spawn(move || { let db = crate::model::msglog::open_or_create(&edit_session_dir) .ok() .map(|c| std::sync::Arc::new(std::sync::Mutex::new(c))); let tc = TurnCtx { client: crate::service::provider::LlmClient::new(api_key, model, base_url), tdefs: tool_defs, tools, ctx, workspace_roots, edit_log_session_dir: edit_session_dir, session_id, db, temperature, max_tokens, abort_flag, }; let result = run_agent_turn(tc, &messages, &events_q); if let Err(e) = result { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Error(e.to_string())); } } if let Ok(mut flag) = in_flight_flag.lock() { *flag = false; } }); } struct TurnCtx { client: crate::service::provider::LlmClient, tdefs: Vec, tools: Vec>, ctx: crate::tool::ToolCtx, workspace_roots: Vec, edit_log_session_dir: std::path::PathBuf, session_id: String, db: Option>>, temperature: f32, max_tokens: u32, abort_flag: std::sync::Arc, } fn archive_message(db: &Option>>, session_id: &str, msg: &ChatMessage) { if let Some(ref arc) = db { if let Ok(conn) = arc.lock() { let _ = crate::model::msglog::insert_message(&conn, session_id, msg); } } } fn run_agent_turn( tc: TurnCtx, messages: &[ChatMessage], events_q: &std::sync::Mutex>, ) -> anyhow::Result<()> { let mut msgs = messages.to_vec(); let mut edits_this_turn = 0u32; let mut tool_only_rounds = 0usize; let mut prev_shaped = false; let system_text = format!( "{}\n\n{}", crate::resources::SYSTEM_PROMPT, crate::resources::SYSTEM_TOOLS, ); if !msgs.iter().any(|m| matches!(m.role, crate::dto::chat::message::Role::System)) { let sys = ChatMessage::system(system_text); archive_message(&tc.db, &tc.session_id, &sys); msgs.insert(0, sys); } for _step in 0..MAX_AGENT_STEPS { if tool_only_rounds >= MAX_TOOL_ONLY_TURNS { let stop_msg = ChatMessage::user( "Stop calling tools. Respond naturally now.".to_string(), ); archive_message(&tc.db, &tc.session_id, &stop_msg); msgs.push(stop_msg); tool_only_rounds = 0; } let wire_msgs = if crate::app::runtime::shortsend::should_shape(msgs.len(), prev_shaped) { let total_chars: usize = msgs.iter() .filter_map(|m| m.content.as_deref()) .map(|c| c.len()) .sum(); let token_estimate = total_chars / 4; prev_shaped = true; crate::app::runtime::shortsend::shape_messages(&msgs, token_estimate) } else { prev_shaped = false; msgs.clone() }; let mut stream_started = false; let mut usage = None; let result = tc.client.chat_with_tools_streaming( &wire_msgs, Some(tc.tdefs.clone()), Some(tc.temperature), Some(tc.max_tokens), |event| -> bool { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { return false; } if let Ok(mut q) = events_q.lock() { match event { crate::app::runtime::stream::StreamEvent::Token(tok) => { if !stream_started { q.push_back(TurnEvent::StreamStart); stream_started = true; } q.push_back(TurnEvent::StreamToken(tok.clone())); } crate::app::runtime::stream::StreamEvent::Usage { prompt_tokens, completion_tokens, .. } => { usage = Some((*prompt_tokens, *completion_tokens)); } _ => {} } } true }, ); let (response, final_usage) = match result { Ok((msg, u)) => (msg, u.or(usage)), Err(e) => { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) || e.to_string().contains("aborted") { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Error("Generation aborted by user".to_string())); } return Ok(()); } let (msg, usage_fb) = tc.client.chat_with_tools_non_streaming( &wire_msgs, Some(tc.tdefs.clone()), )?; (msg, usage_fb) } }; if let Some((tok_in, tok_out)) = final_usage { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Usage { tokens_in: tok_in, tokens_out: tok_out }); } } let has_tool_calls = response.tool_calls.is_some() && response.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()); let content = response.content.clone().unwrap_or_default(); if has_tool_calls { tool_only_rounds += 1; let tool_calls = response.tool_calls.clone().unwrap_or_default(); archive_message(&tc.db, &tc.session_id, &response); msgs.push(response); for tool_call in tool_calls { if tc.abort_flag.load(std::sync::atomic::Ordering::SeqCst) { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Error("Turn aborted by user".to_string())); } return Ok(()); } let tool_name = tool_call.function.name.clone(); let args = crate::dto::chat::tool::sanitize_tool_arguments( &tool_call.function.arguments, ); let ws_roots: Vec<&std::path::Path> = tc.workspace_roots.iter().map(|p| p.as_path()).collect(); let verdict = crate::app::harness::Harness::gate_tool_call( &tool_name, &args, &ws_roots, ); let is_edit_tool = tool_name == "write" || tool_name == "edit"; let (output, is_error, is_edit) = match verdict { Verdict::Allow => match execute_one_tool( &tc.tools, &tc.ctx, &tool_name, &tool_call.id, &args, &tc.edit_log_session_dir, &tc.session_id, &tc.db, ) { Ok(result) => (result, false, is_edit_tool), Err(e) => (e.to_string(), true, false), }, Verdict::Block(reason) => (format!("Blocked: {}", reason), true, false), Verdict::Escalate => ( "Tool requires approval. Provide explicit approval." .to_string(), true, false, ), }; if is_edit { edits_this_turn += 1; } let tool_path = args.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::ToolResult { tool_call_id: tool_call.id.clone(), tool_name: tool_name.clone(), output: output.clone(), is_error, path: tool_path, }); } } let tool_msg = ChatMessage::tool_result(tool_call.id.clone(), output); archive_message(&tc.db, &tc.session_id, &tool_msg); msgs.push(tool_msg); } } else { if !content.is_empty() { archive_message(&tc.db, &tc.session_id, &response); if let Ok(mut q) = events_q.lock() { if stream_started { q.push_back(TurnEvent::StreamDone(response)); } else { q.push_back(TurnEvent::AssistantMessage(response)); } } } break; } } if edits_this_turn > 0 { if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::SystemNote { kind: "edits".to_string(), message: edits_this_turn.to_string(), }); } } if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Done); } Ok(()) } #[allow(clippy::too_many_arguments)] fn execute_one_tool( tools: &[Box], ctx: &crate::tool::ToolCtx, name: &str, tool_call_id: &str, args: &serde_json::Value, session_dir: &std::path::Path, session_id: &str, db: &Option>>, ) -> anyhow::Result { for tool in tools { if tool.name() == name { // Snapshot current file content before write/edit for rewind if (name == "write" || name == "edit") && !tool_call_id.is_empty() { if let Some(ref arc) = db { if let Ok(conn) = arc.lock() { let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); if let Ok(abs_path) = crate::tool::resolve_path(&ctx.workspaces, path) { if let Ok(bytes) = std::fs::read(&abs_path) { let _ = crate::model::msglog::store_blob( &conn, session_id, tool_call_id, &bytes, None, ); } } } } } let result = tool.run(ctx, args)?; if name == "write" || name == "edit" { let reason = args .get("reason") .and_then(|v| v.as_str()) .unwrap_or("unnamed"); let path = args .get("path") .and_then(|v| v.as_str()) .unwrap_or("unknown"); let content_sha256 = { let content = args.get("content").or_else(|| args.get("new")); let hash = sha2::Sha256::digest( content.and_then(|v| v.as_str()).unwrap_or("").as_bytes(), ); format!("{:x}", hash) }; let bytes_delta = if name == "write" { args.get("content") .and_then(|v| v.as_str()) .map(|s| s.len() as i64) .unwrap_or(0) } else { let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); (new.len() as i64 - old.len() as i64).abs() }; let entry = crate::model::editlog::EditLogEntry { ts: chrono::Utc::now().timestamp_millis(), tool: name.to_string(), path: path.to_string(), reason: reason.to_string(), content_sha256, bytes_delta, origin: ctx.origin.tag(), session_id: session_id.to_string(), }; let mut el = crate::model::editlog::EditLog::new(session_dir); el.append(entry).ok(); } return Ok(result); } } anyhow::bail!("tool not found: {}", name) } fn maybe_trigger_review(state: &mut AppStateRest) { if !state.settings.review_enabled { return; } let edit_count = state .session_runtime .as_ref() .map(|rt| rt.edit_count) .unwrap_or(0); if edit_count == 0 { return; } state.push_toast(Toast::new( ToastKind::Info, format!("{} file(s) modified this turn. Review available.", edit_count), )); } fn save_current_session(state: &AppStateRest) { let base = state.store_base_dir(); let session = crate::model::session::Session::new( state.session_id.clone(), "session".to_string(), ); let _ = session.save(&base); if let Some(ref rt) = state.session_runtime { let conv_path = session.conversation_path(&base); if let Ok(data) = serde_json::to_string(&rt.messages) { let _ = std::fs::write(&conv_path, data); } } } fn run_oauth_flow(provider: &str) -> anyhow::Result { use crate::service::oauth::manager::{OAuthConfig, OAuthManager}; use crate::service::oauth::loopback::LoopbackServer; use crate::service::oauth::pkce::CodeVerifier; let config = match provider { "zen" | "opencode" => OAuthConfig { auth_url: "https://opencode.ai/zen/oauth/authorize".to_string(), token_url: "https://opencode.ai/zen/oauth/token".to_string(), client_id: std::env::var("ZEN_CLIENT_ID") .unwrap_or_else(|_| "zesdex".to_string()), client_secret: std::env::var("ZEN_CLIENT_SECRET").ok(), scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], }, "openai" => OAuthConfig { auth_url: "https://auth0.openai.com/authorize".to_string(), token_url: "https://auth0.openai.com/oauth/token".to_string(), client_id: std::env::var("OPENAI_CLIENT_ID") .unwrap_or_else(|_| "zesdex".to_string()), client_secret: std::env::var("OPENAI_CLIENT_SECRET").ok(), scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], }, other => { let auth_url = std::env::var(format!("{}_AUTH_URL", other.to_uppercase())) .map_err(|_| anyhow::anyhow!("unknown provider '{}'. Set {}_AUTH_URL env var.", other, other.to_uppercase()))?; let token_url = std::env::var(format!("{}_TOKEN_URL", other.to_uppercase())) .map_err(|_| anyhow::anyhow!("{}_TOKEN_URL not set", other.to_uppercase()))?; let client_id = std::env::var(format!("{}_CLIENT_ID", other.to_uppercase())) .unwrap_or_else(|_| "zesdex".to_string()); OAuthConfig { auth_url, token_url, client_id, client_secret: std::env::var(format!("{}_CLIENT_SECRET", other.to_uppercase())).ok(), scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()], } } }; let server = LoopbackServer::bind()?; let redirect_uri = server.redirect_uri(); let verifier = CodeVerifier::new(); let challenge = verifier.challenge(); let state_token = format!("{:x}", sha2::Sha256::digest(rand_bytes(16))); let mut manager = OAuthManager::new(config.clone()); let auth_url = manager.build_auth_url(&redirect_uri, &state_token, challenge.as_str()); let _ = webbrowser::open(&auth_url); let code = server.wait_for_code(120_000)?; manager.exchange_code(&code, &redirect_uri, verifier.as_str()) .map_err(|e| anyhow::anyhow!("{}", e))?; if let Some(ref token) = manager.token { let token_path = dirs::config_dir() .unwrap_or_else(|| std::path::PathBuf::from(".")) .join("zesdex") .join(format!("oauth_{}.json", provider)); if let Some(parent) = token_path.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write(&token_path, serde_json::to_string_pretty(token).unwrap_or_default()); } Ok(format!("Successfully authenticated with {}.", provider)) } fn rand_bytes(n: usize) -> Vec { use std::time::{SystemTime, UNIX_EPOCH}; let seed = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos(); (0..n).map(|i| ((seed >> (i % 4 * 8)) ^ (i as u32 * 2654435761)) as u8).collect() }