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