1mod build_settings;
2mod recommendations;
3mod tui_runner;
4use aether_project::user_settings_path;
5pub use build_settings::Preset;
6use llm::catalog::Provider;
7use recommendations::recommended_for_provider;
8use std::fs;
9use std::io;
10use std::path::PathBuf;
11use thiserror::Error;
12
13use crate::init::build_settings::supported_providers;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum InitScope {
17 User,
18 Project,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct InitTarget {
23 pub scope: InitScope,
24 pub settings_path: PathBuf,
25 pub asset_root: PathBuf,
26}
27
28#[derive(Debug, Clone)]
29pub struct InitRequest {
30 pub target: InitTargetRequest,
31 pub provider: Option<Provider>,
32 pub preset: Option<Preset>,
33 pub force: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum InitTargetRequest {
38 User,
39 Project { path: PathBuf },
40}
41
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum InitOutcome {
44 Applied { settings_path: PathBuf, missing_env_var: Option<&'static str> },
45 Cancelled,
46 AlreadyInitialized { settings_path: PathBuf },
47}
48
49#[derive(Debug, Error)]
50pub enum InitError {
51 #[error("could not determine user home directory; set $AETHER_HOME or $HOME")]
52 NoHomeDir,
53 #[error("io error at {path}: {source}")]
54 Io {
55 path: PathBuf,
56 #[source]
57 source: io::Error,
58 },
59 #[error("provider `{provider}` does not have a curated preset; supported: {supported}")]
60 UnsupportedProvider { provider: Provider, supported: String },
61 #[error("terminal error: {0}")]
62 Terminal(#[source] io::Error),
63}
64
65impl InitTarget {
66 pub fn user(aether_home: impl Into<PathBuf>) -> Self {
67 let asset_root = aether_home.into();
68 Self { scope: InitScope::User, settings_path: asset_root.join("settings.json"), asset_root }
69 }
70
71 pub fn project(project_root: impl Into<PathBuf>) -> Self {
72 let project_root = project_root.into();
73 let asset_root = project_root.join(".aether");
74 Self { scope: InitScope::Project, settings_path: asset_root.join("settings.json"), asset_root }
75 }
76}
77
78impl InitRequest {
79 pub fn user_onboarding() -> Self {
80 Self { target: InitTargetRequest::User, provider: None, preset: None, force: false }
81 }
82}
83
84pub fn apply_init(
85 target: InitTarget,
86 provider: Provider,
87 preset: Preset,
88 force: bool,
89) -> Result<InitOutcome, InitError> {
90 if target.settings_path.is_file() && !force {
91 return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
92 }
93
94 let recs = recommended_for_provider(provider).ok_or_else(|| InitError::UnsupportedProvider {
95 provider,
96 supported: supported_providers().map(Provider::parser_name).collect::<Vec<_>>().join(", "),
97 })?;
98
99 let built = build_settings::build_preset(preset, provider, &recs, target.scope);
100
101 fs::create_dir_all(&target.asset_root).map_err(|e| InitError::Io { path: target.asset_root.clone(), source: e })?;
102
103 for file in built.files {
104 let dest = target.asset_root.join(file.path);
105 if let Some(parent) = dest.parent() {
106 fs::create_dir_all(parent).map_err(|e| InitError::Io { path: parent.to_path_buf(), source: e })?;
107 }
108 fs::write(&dest, file.body).map_err(|e| InitError::Io { path: dest, source: e })?;
109 }
110
111 let serialized = serde_json::to_string_pretty(&built.settings).expect("AetherSettings always serializes");
112 fs::write(&target.settings_path, format!("{serialized}\n"))
113 .map_err(|e| InitError::Io { path: target.settings_path.clone(), source: e })?;
114
115 let missing_env_var = provider.required_env_var().filter(|var| std::env::var(var).is_err());
116 Ok(InitOutcome::Applied { settings_path: target.settings_path, missing_env_var })
117}
118
119pub async fn run_init(request: InitRequest) -> Result<InitOutcome, InitError> {
120 let target = resolve_target(&request)?;
121
122 if target.settings_path.is_file() && !request.force {
123 return Ok(InitOutcome::AlreadyInitialized { settings_path: target.settings_path });
124 }
125
126 let Some((provider, preset)) = tui_runner::run_wizard(request.provider, request.preset).await? else {
127 return Ok(InitOutcome::Cancelled);
128 };
129
130 apply_init(target, provider, preset, request.force)
131}
132
133pub fn next_steps_message(outcome: &InitOutcome) -> Option<String> {
134 match outcome {
135 InitOutcome::Applied { settings_path, missing_env_var: Some(var) } => Some(format!(
136 "Wrote {}. Set ${var} in your shell, then run `wisp` or `aether` to start chatting.",
137 settings_path.display()
138 )),
139 InitOutcome::Applied { settings_path, missing_env_var: None } => {
140 Some(format!("Wrote {}. Run `wisp` or `aether` to start chatting.", settings_path.display()))
141 }
142 InitOutcome::AlreadyInitialized { settings_path } => {
143 Some(format!("Already initialized at {}; pass --force to overwrite.", settings_path.display()))
144 }
145 InitOutcome::Cancelled => None,
146 }
147}
148
149fn resolve_target(request: &InitRequest) -> Result<InitTarget, InitError> {
150 match &request.target {
151 InitTargetRequest::User => {
152 let settings_path = user_settings_path().ok_or(InitError::NoHomeDir)?;
153 let home = settings_path.parent().ok_or(InitError::NoHomeDir)?.to_path_buf();
154 Ok(InitTarget::user(home))
155 }
156 InitTargetRequest::Project { path } => {
157 let root = path.canonicalize().unwrap_or_else(|_| path.clone());
158 Ok(InitTarget::project(root))
159 }
160 }
161}