Skip to main content

wekan_cli/
config.rs

1pub mod profiles;
2
3use std::env;
4
5use crate::{
6    client::{ServerUrl, WekanClientFactory},
7    error::{AppError, ErrorCode, ErrorDetails},
8    exit_code::StableExitCode,
9};
10
11use self::profiles::{
12    Profile, ProfileMutation, ProfileSnapshot, ProfileStore, canonical_server,
13    validate_profile_name,
14};
15
16const DEFAULT_PROFILE: &str = "default";
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub(crate) enum MissingProfileResolution {
20    Reject,
21    Initialize,
22    LocalCredentialCleanup,
23}
24
25impl MissingProfileResolution {
26    pub(crate) const fn allows_initialization(self) -> bool {
27        matches!(self, Self::Initialize)
28    }
29}
30
31#[derive(Clone, Debug, Default)]
32pub struct ServerSelection {
33    pub explicit_server: Option<String>,
34    pub explicit_profile: Option<String>,
35    pub allow_insecure_http: bool,
36}
37
38impl ServerSelection {
39    pub fn new(
40        explicit_server: Option<String>,
41        explicit_profile: Option<String>,
42        allow_insecure_http: bool,
43    ) -> Self {
44        Self {
45            explicit_server,
46            explicit_profile,
47            allow_insecure_http,
48        }
49    }
50}
51
52#[derive(Clone, Debug, Default)]
53struct ServerEnvironment {
54    server: Option<String>,
55    profile: Option<String>,
56}
57
58impl ServerEnvironment {
59    fn read(selection: &ServerSelection) -> Result<Self, AppError> {
60        Ok(Self {
61            server: if selection.explicit_server.is_some() {
62                None
63            } else {
64                read_unicode_environment("WEKAN_URL")?
65            },
66            profile: if selection.explicit_profile.is_some() {
67                None
68            } else {
69                read_unicode_environment("WEKAN_PROFILE")?
70            },
71        })
72    }
73}
74
75fn read_unicode_environment(name: &str) -> Result<Option<String>, AppError> {
76    match env::var(name) {
77        Ok(value) => Ok(Some(value)),
78        Err(env::VarError::NotPresent) => Ok(None),
79        Err(env::VarError::NotUnicode(_)) => Err(AppError::configuration(format!(
80            "{name} must contain valid Unicode"
81        ))),
82    }
83}
84
85#[derive(Clone, Debug)]
86pub struct TargetResolver {
87    selection: ServerSelection,
88}
89
90impl TargetResolver {
91    pub const fn new(selection: ServerSelection) -> Self {
92        Self { selection }
93    }
94
95    pub fn has_explicit_target(&self) -> bool {
96        self.selection.explicit_server.is_some() || self.selection.explicit_profile.is_some()
97    }
98
99    pub(crate) fn resolve<'a>(
100        &self,
101        profile_store: &'a dyn ProfileStore,
102        missing_profile_resolution: MissingProfileResolution,
103    ) -> Result<ResolvedTarget<'a>, AppError> {
104        self.resolve_with_environment(
105            profile_store,
106            ServerEnvironment::read(&self.selection)?,
107            missing_profile_resolution,
108        )
109    }
110
111    fn resolve_with_environment<'a>(
112        &self,
113        profile_store: &'a dyn ProfileStore,
114        environment: ServerEnvironment,
115        missing_profile_resolution: MissingProfileResolution,
116    ) -> Result<ResolvedTarget<'a>, AppError> {
117        let selected_name = self.selected_name_before_active(&environment)?;
118        let snapshot = profile_store
119            .read()
120            .map_err(AppError::from)
121            .map_err(|error| with_optional_target_context(error, selected_name.clone()))?;
122        let name = selected_name.unwrap_or_else(|| {
123            snapshot
124                .document()
125                .active_profile()
126                .unwrap_or(DEFAULT_PROFILE)
127                .to_owned()
128        });
129        let supplied_server = self
130            .selection
131            .explicit_server
132            .as_ref()
133            .or(environment.server.as_ref())
134            .map(|raw| canonical_server(raw))
135            .transpose()
136            .map_err(AppError::from)
137            .map_err(|error| with_target_context(error, &name))?;
138        let credential_namespace = profile_store
139            .credential_namespace()
140            .map_err(AppError::from)
141            .map_err(|error| with_target_context(error, &name))?;
142
143        if let Some(profile) = snapshot.document().profile(&name) {
144            let server = verified_server(&name, profile.server(), supplied_server.as_deref())?;
145            let active = snapshot.document().active_profile() == Some(name.as_str());
146            return Ok(ResolvedTarget::existing(
147                server,
148                name,
149                credential_namespace,
150                active,
151                self.selection.allow_insecure_http,
152                snapshot,
153            ));
154        }
155
156        let Some(server) = supplied_server else {
157            return Err(profile_not_found(&name));
158        };
159
160        match missing_profile_resolution {
161            MissingProfileResolution::Reject => return Err(profile_not_found(&name)),
162            MissingProfileResolution::LocalCredentialCleanup => {
163                let server = ServerUrl::parse(&server)
164                    .map_err(AppError::from)
165                    .map_err(|error| with_target_context(error, &name))?;
166                return Ok(ResolvedTarget::missing_profile_cleanup(
167                    server,
168                    name,
169                    credential_namespace,
170                    self.selection.allow_insecure_http,
171                    snapshot,
172                ));
173            }
174            MissingProfileResolution::Initialize => {}
175        }
176
177        drop(snapshot);
178        let mutation = profile_store
179            .lock_mutation()
180            .map_err(AppError::from)
181            .map_err(|error| with_target_context(error, &name))?;
182        if let Some(profile) = mutation.document().profile(&name) {
183            let server = verified_server(&name, profile.server(), Some(&server))?;
184            let active = mutation.document().active_profile() == Some(name.as_str());
185            return Ok(ResolvedTarget::under_mutation(
186                server,
187                name,
188                credential_namespace,
189                active,
190                false,
191                self.selection.allow_insecure_http,
192                mutation,
193            ));
194        }
195
196        let server = ServerUrl::parse(&server)
197            .map_err(AppError::from)
198            .map_err(|error| with_target_context(error, &name))?;
199        Ok(ResolvedTarget::under_mutation(
200            server,
201            name,
202            credential_namespace,
203            false,
204            true,
205            self.selection.allow_insecure_http,
206            mutation,
207        ))
208    }
209
210    fn selected_name_before_active(
211        &self,
212        environment: &ServerEnvironment,
213    ) -> Result<Option<String>, AppError> {
214        if let Some(name) = &self.selection.explicit_profile {
215            validate_profile_name(name)
216                .map_err(AppError::invalid_input)
217                .map_err(|error| with_target_context(error, name))?;
218            return Ok(Some(name.clone()));
219        }
220        if let Some(name) = &environment.profile {
221            validate_profile_name(name)
222                .map_err(|message| {
223                    AppError::configuration(format!("WEKAN_PROFILE is invalid: {message}"))
224                })
225                .map_err(|error| with_target_context(error, name))?;
226            return Ok(Some(name.clone()));
227        }
228        Ok(None)
229    }
230}
231
232fn verified_server(
233    name: &str,
234    stored_server: &str,
235    supplied_server: Option<&str>,
236) -> Result<ServerUrl, AppError> {
237    if supplied_server.is_some_and(|supplied| supplied != stored_server) {
238        return Err(AppError::new(
239            ErrorCode::ProfileServerMismatch,
240            format!(
241                "the supplied server URL does not match profile `{name}`; the stored profile was not changed"
242            ),
243            StableExitCode::Configuration,
244        )
245        .with_details(ErrorDetails {
246            profile: Some(name.to_owned()),
247            ..ErrorDetails::default()
248        }));
249    }
250    ServerUrl::parse(stored_server)
251        .map_err(AppError::from)
252        .map_err(|error| with_target_context(error, name))
253}
254
255fn profile_not_found(name: &str) -> AppError {
256    AppError::new(
257        ErrorCode::ProfileNotFound,
258        format!(
259            "profile `{name}` does not exist; add it with `wekan profile add`, supply --server to auth login or auth register, or run `wekan --server <URL> --profile {name} auth logout --local-only --yes` to clear an orphaned local credential"
260        ),
261        StableExitCode::Configuration,
262    )
263    .with_details(ErrorDetails {
264        profile: Some(name.to_owned()),
265        ..ErrorDetails::default()
266    })
267}
268
269fn with_target_context(error: AppError, profile: &str) -> AppError {
270    if error.details().profile.is_some() {
271        error
272    } else {
273        error.with_profile_context(profile.to_owned())
274    }
275}
276
277fn with_optional_target_context(error: AppError, profile: Option<String>) -> AppError {
278    match profile {
279        Some(profile) => with_target_context(error, &profile),
280        None => error,
281    }
282}
283
284enum ProfileGuard<'a> {
285    Existing {
286        _snapshot: ProfileSnapshot,
287    },
288    Mutation {
289        mutation: Box<dyn ProfileMutation + Send + 'a>,
290        needs_creation: bool,
291    },
292}
293
294pub struct ResolvedTarget<'a> {
295    client_factory: WekanClientFactory,
296    profile: String,
297    credential_namespace: String,
298    profile_created: bool,
299    profile_active: bool,
300    guard: ProfileGuard<'a>,
301}
302
303impl<'a> ResolvedTarget<'a> {
304    fn existing(
305        server: ServerUrl,
306        profile: String,
307        credential_namespace: String,
308        active: bool,
309        allow_insecure_http: bool,
310        snapshot: ProfileSnapshot,
311    ) -> Self {
312        Self::with_snapshot(
313            server,
314            profile,
315            credential_namespace,
316            active,
317            allow_insecure_http,
318            snapshot,
319        )
320    }
321
322    fn missing_profile_cleanup(
323        server: ServerUrl,
324        profile: String,
325        credential_namespace: String,
326        allow_insecure_http: bool,
327        snapshot: ProfileSnapshot,
328    ) -> Self {
329        Self::with_snapshot(
330            server,
331            profile,
332            credential_namespace,
333            false,
334            allow_insecure_http,
335            snapshot,
336        )
337    }
338
339    fn with_snapshot(
340        server: ServerUrl,
341        profile: String,
342        credential_namespace: String,
343        active: bool,
344        allow_insecure_http: bool,
345        snapshot: ProfileSnapshot,
346    ) -> Self {
347        Self {
348            client_factory: WekanClientFactory::for_resolved_profile(
349                server,
350                profile.clone(),
351                credential_namespace.clone(),
352                allow_insecure_http,
353            ),
354            profile,
355            credential_namespace,
356            profile_created: false,
357            profile_active: active,
358            guard: ProfileGuard::Existing {
359                _snapshot: snapshot,
360            },
361        }
362    }
363
364    #[allow(clippy::too_many_arguments)]
365    fn under_mutation(
366        server: ServerUrl,
367        profile: String,
368        credential_namespace: String,
369        active: bool,
370        needs_creation: bool,
371        allow_insecure_http: bool,
372        mutation: Box<dyn ProfileMutation + Send + 'a>,
373    ) -> Self {
374        Self {
375            client_factory: WekanClientFactory::for_resolved_profile(
376                server,
377                profile.clone(),
378                credential_namespace.clone(),
379                allow_insecure_http,
380            ),
381            profile,
382            credential_namespace,
383            profile_created: false,
384            profile_active: active,
385            guard: ProfileGuard::Mutation {
386                mutation,
387                needs_creation,
388            },
389        }
390    }
391
392    pub const fn client_factory(&self) -> &WekanClientFactory {
393        &self.client_factory
394    }
395
396    pub fn profile(&self) -> &str {
397        &self.profile
398    }
399
400    pub fn credential_namespace(&self) -> &str {
401        &self.credential_namespace
402    }
403
404    pub const fn profile_created(&self) -> bool {
405        self.profile_created
406    }
407
408    pub const fn profile_active(&self) -> bool {
409        self.profile_active
410    }
411
412    pub fn initialize_profile(&mut self) -> Result<(), AppError> {
413        let profile = self.profile.clone();
414        let ProfileGuard::Mutation {
415            mutation,
416            needs_creation,
417        } = &mut self.guard
418        else {
419            return Ok(());
420        };
421        if !*needs_creation {
422            return Ok(());
423        }
424
425        let mut document = mutation.document().clone();
426        let active = document.is_empty();
427        document.insert(
428            self.profile.clone(),
429            Profile::new(self.client_factory.server_identity().as_str().to_owned()),
430        );
431        if active {
432            document.set_active_profile(Some(self.profile.clone()));
433        }
434        if let Err(error) = mutation.save(document) {
435            if error.profile_was_installed() {
436                *needs_creation = false;
437                self.profile_created = true;
438                self.profile_active = active;
439            }
440            return Err(with_target_context(AppError::from(error), &profile)
441                .with_profile_state(self.profile_created, self.profile_active));
442        }
443        *needs_creation = false;
444        self.profile_created = true;
445        self.profile_active = active;
446        Ok(())
447    }
448}
449
450#[cfg(test)]
451impl ResolvedTarget<'static> {
452    pub(crate) fn for_test(client_factory: &WekanClientFactory) -> Self {
453        Self {
454            client_factory: WekanClientFactory::for_resolved_profile(
455                client_factory.server_identity().clone(),
456                client_factory
457                    .profile()
458                    .unwrap_or(DEFAULT_PROFILE)
459                    .to_owned(),
460                client_factory
461                    .profile_store_namespace()
462                    .unwrap_or("test-store")
463                    .to_owned(),
464                false,
465            ),
466            profile: client_factory
467                .profile()
468                .unwrap_or(DEFAULT_PROFILE)
469                .to_owned(),
470            credential_namespace: client_factory
471                .profile_store_namespace()
472                .unwrap_or("test-store")
473                .to_owned(),
474            profile_created: false,
475            profile_active: false,
476            guard: ProfileGuard::Existing {
477                _snapshot: ProfileSnapshot::new_unlocked(Default::default()),
478            },
479        }
480    }
481}
482
483#[cfg(test)]
484mod tests;