Skip to main content

kasl/api/
si.rs

1//! Internal SiServer API client for company reporting and calendar integration.
2//!
3//! Provides integration with an internal company API system that handles employee
4//! time tracking reports and company calendar information.
5//!
6//! ## Features
7//!
8//! - **Report Submission**: Submit daily and monthly time tracking reports
9//! - **Calendar Integration**: Fetch company rest dates and holidays
10//! - **Two-Stage Authentication**: LDAP authentication followed by session token exchange
11//! - **Error Resilience**: Graceful handling of network failures and API errors
12//! - **Session Management**: Automatic session caching and renewal
13//!
14//! ## Usage
15//!
16//! ```rust,no_run
17//! # use kasl::api::si::{Si, SiConfig};
18//! # use chrono::Local;
19//! # async fn f() -> anyhow::Result<()> {
20//! let config = SiConfig {
21//!     login: "username".to_string(),
22//!     auth_url: "https://auth.company.com".to_string(),
23//!     api_url: "https://api.company.com".to_string(),
24//! };
25//!
26//! let mut si = Si::new(&config);
27//! let today = Local::now().date_naive();
28//! let rest_dates = si.rest_dates(today).await?;
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::{
34    api::Session,
35    libs::{config::ConfigModule, messages::Message, secret::Secret},
36    msg_error, msg_print,
37};
38use anyhow::Result;
39use base64::prelude::*;
40use chrono::{Datelike, Duration, NaiveDate, Weekday};
41use dialoguer::{Input, theme::ColorfulTheme};
42use reqwest::{
43    Client, StatusCode,
44    header::{self, COOKIE, HeaderMap, HeaderValue},
45    multipart,
46};
47use serde::{Deserialize, Serialize};
48use std::collections::HashSet;
49
50/// Maximum number of authentication retries before giving up.
51/// SiServer has more complex auth flow, so we use the same conservative limit.
52const MAX_RETRY_COUNT: i32 = 3;
53
54/// Cookie name prefix used by SiServer for session identification.
55const COOKIE_KEY: &str = "PORTALSESSID=";
56
57/// Filename for storing SiServer session tokens in the user data directory.
58const SESSION_ID_FILE: &str = ".si_session_id";
59
60/// Filename for storing encrypted SiServer credentials for password caching.
61const SECRET_FILE: &str = ".si_secret";
62
63/// SiServer API endpoint for LDAP authentication (first stage).
64const AUTH_URL: &str = "auth/ldap";
65
66/// SiServer API endpoint for token-to-session exchange (second stage).
67const LOGIN_URL: &str = "auth/login-by-token";
68
69/// SiServer API endpoint for submitting daily time reports.
70const REPORT_URL: &str = "report-card/send-daily-report";
71
72/// SiServer API endpoint for submitting monthly summary reports.
73const MONTHLY_REPORT_URL: &str = "report-card/send-monthly-report";
74
75/// SiServer API endpoint for fetching company rest dates and holidays.
76const REST_DATES_URL: &str = "report-card/get-rest-dates";
77
78/// User credentials for SiServer authentication.
79///
80/// SiServer requires special password encoding (double base64) for security.
81/// Credentials are only held in memory during the authentication process.
82#[derive(Serialize, Clone, Debug)]
83pub struct LoginCredentials {
84    /// Username for LDAP authentication
85    login: String,
86    /// Double base64-encoded password for enhanced security
87    password: String,
88}
89
90/// Response structure for SiServer LDAP authentication.
91///
92/// The first stage of authentication returns a temporary token that must
93/// be exchanged for a session cookie in the second stage.
94#[derive(Deserialize)]
95pub struct AuthSession {
96    /// Payload containing the authentication token
97    payload: AuthPayload,
98}
99
100/// Authentication payload containing the temporary token.
101///
102/// This token is used to authenticate the second stage of the login process
103/// where it's exchanged for a session cookie.
104#[derive(Deserialize)]
105pub struct AuthPayload {
106    /// Temporary authentication token for session exchange
107    token: String,
108}
109
110/// Response structure for SiServer rest dates API.
111///
112/// SiServer provides company calendar information including various types
113/// of non-working days such as holidays, vacation days, and weekend days.
114/// Different date arrays represent different types of rest periods.
115#[derive(Debug, Deserialize)]
116pub struct RestDatesResponse {
117    /// Regular rest dates (general holidays)
118    dates: Vec<String>,
119    /// Vacation dates (company-specific holidays)
120    v_dates: Vec<String>,
121    /// Weekend dates (extended weekend periods)
122    w_dates: Vec<String>,
123}
124
125impl RestDatesResponse {
126    /// Parses and combines all rest dates into a single unified set.
127    ///
128    /// This method processes all three categories of rest dates and combines them
129    /// into a single `HashSet` for easy lookup operations. Duplicate dates across
130    /// categories are automatically deduplicated.
131    ///
132    /// ## Date Format Handling
133    ///
134    /// The API returns dates in "YYYY-MM-DD" format. Invalid date strings are
135    /// silently ignored to handle potential API inconsistencies gracefully.
136    ///
137    /// # Returns
138    ///
139    /// * `Result<HashSet<NaiveDate>>` - Unified set of all rest dates
140    ///
141    /// # Errors
142    ///
143    /// Currently cannot fail, but returns `Result` for future error handling
144    /// such as date validation or API response verification.
145    pub fn unique_dates(&self) -> Result<HashSet<NaiveDate>> {
146        let mut date_set = HashSet::new();
147
148        // Process all three date categories
149        self.process_dates(&self.dates, &mut date_set)?;
150        self.process_dates(&self.v_dates, &mut date_set)?;
151        self.process_dates(&self.w_dates, &mut date_set)?;
152
153        Ok(date_set)
154    }
155
156    /// Helper function to parse date strings and add them to the result set.
157    ///
158    /// Processes a vector of date strings, attempting to parse each one into
159    /// a `NaiveDate`. Invalid dates are silently skipped to handle API
160    /// inconsistencies without failing the entire operation.
161    ///
162    /// # Arguments
163    ///
164    /// * `dates` - Vector of date strings in "YYYY-MM-DD" format
165    /// * `date_set` - Mutable reference to the result set for adding parsed dates
166    ///
167    /// # Returns
168    ///
169    /// Always returns `Ok(())` as this operation cannot fail.
170    fn process_dates(&self, dates: &[String], date_set: &mut HashSet<NaiveDate>) -> Result<()> {
171        dates
172            .iter()
173            .filter_map(|date_str| NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok())
174            .for_each(|date| {
175                date_set.insert(date);
176            });
177        Ok(())
178    }
179}
180
181/// SiServer API client with advanced session management.
182///
183/// This client handles the complex two-stage authentication flow required by
184/// SiServer and provides methods for report submission and calendar data retrieval.
185/// It implements resilient error handling to ensure application stability.
186///
187/// ## Thread Safety
188///
189/// The client is not thread-safe due to mutable retry state. Each thread
190/// should use its own client instance for concurrent operations.
191///
192/// ## Authentication Architecture
193///
194/// SiServer uses a sophisticated authentication system:
195/// 1. **LDAP Stage**: Credentials sent to LDAP endpoint, token received
196/// 2. **Session Stage**: Token sent to session endpoint, cookie received
197/// 3. **API Usage**: Cookie included in all subsequent API requests
198///
199/// This design provides enhanced security but requires careful session management.
200#[derive(Debug)]
201pub struct Si {
202    /// HTTP client for making API requests with connection pooling
203    client: Client,
204    /// Configuration containing API endpoints and user information
205    config: SiConfig,
206    /// In-memory storage for authentication credentials during auth process
207    credentials: Option<LoginCredentials>,
208    /// Counter for tracking authentication retry attempts
209    retries: i32,
210}
211
212impl Session for Si {
213    /// Performs two-stage authentication with SiServer.
214    ///
215    /// This method implements SiServer's unique authentication flow which requires
216    /// two separate API calls to establish a session. The process is more complex
217    /// than standard session authentication but provides enhanced security.
218    ///
219    /// ## Authentication Process
220    ///
221    /// 1. **LDAP Authentication**: Send credentials to LDAP endpoint
222    /// 2. **Token Extraction**: Parse authentication token from response
223    /// 3. **Session Exchange**: Send token to session endpoint with Bearer auth
224    /// 4. **Cookie Extraction**: Parse session cookie from Set-Cookie header
225    /// 5. **Format Preparation**: Extract session ID for use in subsequent requests
226    ///
227    /// ## Error Scenarios
228    ///
229    /// - LDAP authentication failure (invalid credentials)
230    /// - Token parsing failure (unexpected response format)
231    /// - Session exchange failure (token expired or invalid)
232    /// - Cookie extraction failure (missing or malformed Set-Cookie header)
233    ///
234    /// # Returns
235    ///
236    /// Returns the session ID string extracted from the PORTALSESSID cookie.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if:
241    /// - No credentials have been set (programming error)
242    /// - Network requests fail
243    /// - LDAP authentication fails
244    /// - Token or cookie parsing fails
245    /// - Authentication flow completes but no valid session is established
246    async fn login(&self) -> Result<String> {
247        // Ensure credentials are available for authentication
248        let credentials = self.credentials.clone().expect("Credentials not set!");
249
250        // Stage 1: LDAP Authentication
251        let auth_url = format!("{}/{}", self.config.auth_url, AUTH_URL);
252        let auth_res = self.client.post(auth_url).json(&credentials).send().await?;
253        let auth_body = auth_res.text().await?;
254        let auth_session: AuthSession = serde_json::from_str(&auth_body)?;
255
256        // Stage 2: Token-to-Session Exchange
257        let login_url = format!("{}/{}", self.config.api_url, LOGIN_URL);
258        let login_res = self
259            .client
260            .post(login_url)
261            .header(header::AUTHORIZATION, format!("Bearer {}", auth_session.payload.token))
262            .send()
263            .await?;
264
265        // Stage 3: Cookie Extraction
266        if let Some(cookie) = login_res.headers().get("Set-Cookie")
267            && let Ok(cookie_val) = cookie.to_str()
268        {
269            // Find the PORTALSESSID cookie in the Set-Cookie header
270            if let Some(portalsessid) = cookie_val.split(";").find(|c| c.starts_with(COOKIE_KEY)) {
271                let session_id = portalsessid.trim_start_matches(COOKIE_KEY);
272                return Ok(session_id.to_string());
273            }
274        }
275
276        // Authentication completed but no valid session cookie was found
277        anyhow::bail!("Login failed")
278    }
279
280    /// Sets user credentials with SiServer-specific password encoding.
281    ///
282    /// SiServer requires passwords to be double base64-encoded for security.
283    /// This method handles the encoding and stores credentials in memory for
284    /// use during the authentication process.
285    ///
286    /// ## Password Encoding
287    ///
288    /// The password undergoes double base64 encoding:
289    /// 1. First encoding: `base64(password)`
290    /// 2. Second encoding: `base64(base64(password))`
291    ///
292    /// This provides additional security layers for credential transmission.
293    ///
294    /// # Arguments
295    ///
296    /// * `password` - The user's SiServer password in plain text
297    ///
298    /// # Returns
299    ///
300    /// Always returns `Ok(())` as encoding cannot fail.
301    fn set_credentials(&mut self, password: &str) -> Result<()> {
302        // Apply double base64 encoding as required by SiServer
303        let encoded_password = BASE64_STANDARD.encode(BASE64_STANDARD.encode(password));
304
305        self.credentials = Some(LoginCredentials {
306            login: self.config.login.to_string(),
307            password: encoded_password,
308        });
309        Ok(())
310    }
311
312    /// Returns the filename for storing SiServer session tokens.
313    ///
314    /// The session file is stored in the user's application data directory
315    /// and contains the cached session token for automatic login restoration.
316    fn session_id_file(&self) -> &str {
317        SESSION_ID_FILE
318    }
319
320    /// Returns a configured Secret instance for secure password prompting.
321    ///
322    /// The Secret manager handles secure password input with hidden characters
323    /// and optional encrypted caching in the user's data directory.
324    ///
325    /// # Returns
326    ///
327    /// A configured `Secret` instance with SiServer-specific prompts and file names.
328    fn secret(&self) -> Secret {
329        Secret::new(SECRET_FILE, "Enter your SiServer password")
330    }
331
332    /// Returns the current authentication retry count.
333    ///
334    /// Used by the session management system to track failed authentication
335    /// attempts and implement retry limits.
336    fn retry(&self) -> i32 {
337        self.retries
338    }
339
340    /// Increments the authentication retry counter.
341    ///
342    /// Called after each failed authentication attempt to track progress
343    /// toward the maximum retry limit.
344    fn inc_retry(&mut self) {
345        self.retries += 1;
346    }
347
348    /// Resets the authentication retry counter to zero.
349    ///
350    /// Called after successful authentication to ensure future session
351    /// requests start with a clean slate.
352    fn reset_retry(&mut self) {
353        self.retries = 0;
354    }
355}
356
357impl Si {
358    /// Creates a new SiServer API client instance.
359    ///
360    /// Initializes the HTTP client with default settings suitable for SiServer API
361    /// interactions. The client is configured for both JSON and multipart requests
362    /// to handle different SiServer endpoints appropriately.
363    ///
364    /// # Arguments
365    ///
366    /// * `config` - SiServer configuration containing API endpoints and user information
367    ///
368    /// # Examples
369    ///
370    /// ```rust,no_run
371    /// use kasl::api::si::{Si, SiConfig};
372    ///
373    /// let config = SiConfig {
374    ///     login: "username".to_string(),
375    ///     auth_url: "https://auth.company.com".to_string(),
376    ///     api_url: "https://api.company.com".to_string(),
377    /// };
378    /// let si = Si::new(&config);
379    /// ```
380    pub fn new(config: &SiConfig) -> Self {
381        Self {
382            client: Client::new(),
383            config: config.clone(),
384            credentials: None,
385            retries: 0,
386        }
387    }
388
389    /// Submits a daily time tracking report to SiServer.
390    ///
391    /// This method sends formatted daily report data to the SiServer API for
392    /// payroll and time tracking integration. It handles session management
393    /// and implements retry logic for authentication failures.
394    ///
395    /// ## Report Format
396    ///
397    /// The report data should be a JSON string containing:
398    /// - Work hours and break information
399    /// - Task completion details
400    /// - Productivity metrics
401    /// - Any relevant metadata for the specified date
402    ///
403    /// ## Session Management
404    ///
405    /// The method implements automatic session handling:
406    /// 1. **Session Retrieval**: Get or create a valid session token
407    /// 2. **Report Submission**: Send report data with session authentication
408    /// 3. **Error Handling**: Detect expired sessions and retry with re-authentication
409    /// 4. **Status Return**: Return HTTP status for caller handling
410    ///
411    /// # Arguments
412    ///
413    /// * `data` - JSON string containing the formatted report data
414    /// * `date` - The date for which the report is being submitted
415    ///
416    /// # Returns
417    ///
418    /// Returns the HTTP status code from the API response, allowing callers
419    /// to determine success or specific failure modes.
420    ///
421    /// # Errors
422    ///
423    /// Returns an error if:
424    /// - Session management fails persistently
425    /// - Network request fails
426    /// - Request formatting fails
427    /// - Duration conversion fails (internal error)
428    ///
429    /// # Examples
430    ///
431    /// ```rust,no_run
432    /// # use kasl::api::si::{Si, SiConfig};
433    /// # use chrono::Local;
434    /// # use anyhow::Result;
435    /// # async fn example() -> Result<()> {
436    /// # let config = SiConfig {
437    /// #     login: "username".to_string(),
438    /// #     auth_url: "https://auth.company.com".to_string(),
439    /// #     api_url: "https://api.company.com".to_string(),
440    /// # };
441    /// let mut si = Si::new(&config);
442    /// let report_data = r#"{"hours": 8, "tasks": 5}"#.to_string();
443    /// let today = Local::now().date_naive();
444    ///
445    /// let status = si.send(&report_data, &today).await?;
446    /// if status.is_success() {
447    ///     println!("Report submitted successfully");
448    /// }
449    /// # Ok(())
450    /// # }
451    /// ```
452    pub async fn send(&mut self, data: &str, date: &NaiveDate) -> Result<StatusCode> {
453        let mut local_retries = 0;
454        loop {
455            // Get valid session for API request
456            let session_id = self.get_session_id().await?;
457            let url = format!("{}/{}", self.config.api_url, REPORT_URL);
458            let date = date.format("%Y-%m-%d").to_string();
459
460            // Prepare multipart form data for submission
461            let form = multipart::Form::new()
462                .text("date", date)
463                .text("tasks", data.to_owned())
464                .text("comment", "")
465                .text("day_type", "1")
466                .text("duty", "0")
467                .text("only_save", "0");
468
469            // Set up authentication headers
470            let mut headers = HeaderMap::new();
471            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
472
473            // Submit the report
474            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
475                Ok(response) => response,
476                Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
477            };
478
479            // Handle response and potential session expiration
480            match res.status() {
481                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
482                    // Session expired - clear cache and retry
483                    self.delete_session_id()?;
484                    tokio::time::sleep(Duration::seconds(1).to_std()?).await;
485                    local_retries += 1;
486                    continue;
487                }
488                _ => return Ok(res.status()),
489            }
490        }
491    }
492
493    /// Submits a monthly summary report to SiServer.
494    ///
495    /// Sends aggregated monthly statistics to the SiServer API for organizational
496    /// reporting and payroll integration. The report covers the entire month
497    /// containing the specified date.
498    ///
499    /// ## Monthly Report Contents
500    ///
501    /// The system automatically generates a summary containing:
502    /// - Total working hours for the month
503    /// - Number of working days
504    /// - Average daily productivity
505    /// - Compliance with company policies
506    ///
507    /// ## Last Working Day Logic
508    ///
509    /// Monthly reports are typically submitted on the last working day of each month.
510    /// The system can automatically detect this condition and prompt for submission.
511    ///
512    /// # Arguments
513    ///
514    /// * `date` - Any date within the target month for report generation
515    ///
516    /// # Returns
517    ///
518    /// Returns the HTTP status code from the API response.
519    ///
520    /// # Examples
521    ///
522    /// ```rust,no_run
523    /// # use kasl::api::si::{Si, SiConfig};
524    /// # use chrono::Local;
525    /// # use anyhow::Result;
526    /// # async fn example() -> Result<()> {
527    /// # let config = SiConfig {
528    /// #     login: "username".to_string(),
529    /// #     auth_url: "https://auth.company.com".to_string(),
530    /// #     api_url: "https://api.company.com".to_string(),
531    /// # };
532    /// let mut si = Si::new(&config);
533    /// let today = Local::now().date_naive();
534    ///
535    /// if si.is_last_working_day_of_month(&today)? {
536    ///     let status = si.send_monthly(&today).await?;
537    ///     if status.is_success() {
538    ///         println!("Monthly report submitted");
539    ///     }
540    /// }
541    /// # Ok(())
542    /// # }
543    /// ```
544    pub async fn send_monthly(&mut self, date: &NaiveDate) -> Result<StatusCode> {
545        let mut local_retries = 0;
546        loop {
547            // Get valid session for API request
548            let session_id = self.get_session_id().await?;
549            let url = format!("{}/{}", self.config.api_url, MONTHLY_REPORT_URL);
550            let (year, month) = (date.year(), date.month());
551
552            // Prepare monthly report form data
553            let form = multipart::Form::new().text("month", month.to_string()).text("year", year.to_string());
554
555            // Set up authentication headers
556            let mut headers = HeaderMap::new();
557            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
558
559            // Submit the monthly report
560            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
561                Ok(response) => response,
562                Err(_) => return Ok(StatusCode::BAD_REQUEST), // Network error fallback
563            };
564
565            // Handle response and potential session expiration
566            match res.status() {
567                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
568                    // Session expired - clear cache and retry
569                    self.delete_session_id()?;
570                    tokio::time::sleep(Duration::seconds(1).to_std()?).await;
571                    local_retries += 1;
572                    continue;
573                }
574                _ => return Ok(res.status()),
575            }
576        }
577    }
578
579    /// Fetches company rest dates and holidays for the specified year.
580    ///
581    /// This method retrieves the official company calendar including holidays,
582    /// vacation days, and extended weekend periods. The data is used for accurate
583    /// productivity calculations and report generation.
584    ///
585    /// ## Error Resilience
586    ///
587    /// This function prioritizes application stability over data completeness:
588    /// - Network errors return empty results rather than failing
589    /// - Authentication failures are logged but don't interrupt operation
590    /// - API parsing errors result in empty calendar (graceful degradation)
591    /// - Session failures are handled with automatic retry
592    ///
593    /// This design ensures that calendar integration enhances functionality
594    /// without breaking core time tracking features when services are unavailable.
595    ///
596    /// ## Date Processing
597    ///
598    /// The API returns three categories of rest dates:
599    /// - Regular holidays (national and company holidays)
600    /// - Vacation dates (company-specific rest periods)
601    /// - Weekend extensions (long weekend periods)
602    ///
603    /// All categories are combined into a single set for unified processing.
604    ///
605    /// # Arguments
606    ///
607    /// * `year` - Any date within the target year for calendar retrieval
608    ///
609    /// # Returns
610    ///
611    /// Returns a `HashSet<NaiveDate>` containing all rest dates for the year.
612    /// Returns an empty set on any error to ensure graceful degradation.
613    ///
614    /// # Examples
615    ///
616    /// ```rust,no_run
617    /// # use kasl::api::si::{Si, SiConfig};
618    /// # use chrono::Local;
619    /// # use anyhow::Result;
620    /// # async fn example() -> Result<()> {
621    /// # let config = SiConfig {
622    /// #     login: "username".to_string(),
623    /// #     auth_url: "https://auth.company.com".to_string(),
624    /// #     api_url: "https://api.company.com".to_string(),
625    /// # };
626    /// let mut si = Si::new(&config);
627    /// let this_year = Local::now().date_naive();
628    ///
629    /// let rest_dates = si.rest_dates(this_year).await?;
630    /// println!("Found {} rest dates this year", rest_dates.len());
631    ///
632    /// // Check if a specific date is a rest day
633    /// let today = Local::now().date_naive();
634    /// if rest_dates.contains(&today) {
635    ///     println!("Today is a company rest day");
636    /// }
637    /// # Ok(())
638    /// # }
639    /// ```
640    pub async fn rest_dates(&mut self, year: NaiveDate) -> Result<HashSet<NaiveDate>> {
641        let mut local_retries = 0;
642        loop {
643            // Get valid session for API request
644            let session_id = match self.get_session_id().await {
645                Ok(id) => id,
646                Err(e) => {
647                    msg_error!(Message::SiServerSessionFailed(e.to_string()));
648                    return Ok(HashSet::new()); // Return empty set on session failure
649                }
650            };
651
652            // Prepare rest dates request
653            let url = format!("{}/{}", self.config.api_url, REST_DATES_URL);
654            let form = multipart::Form::new().text("year", year.format("%Y").to_string());
655            let mut headers = HeaderMap::new();
656            headers.insert(COOKIE, HeaderValue::from_str(&format!("{}{}", COOKIE_KEY, session_id))?);
657
658            // Request rest dates from API
659            let res = match self.client.post(url).headers(headers).multipart(form).send().await {
660                Ok(resp) => resp,
661                Err(e) => {
662                    msg_error!(Message::SiServerRestDatesFailed(e.to_string()));
663                    return Ok(HashSet::new()); // Return empty set on network error
664                }
665            };
666
667            // Handle response and potential session expiration
668            match res.status() {
669                StatusCode::UNAUTHORIZED if local_retries < MAX_RETRY_COUNT => {
670                    // Session expired - clear cache and retry
671                    self.delete_session_id()?;
672                    local_retries += 1;
673                    continue;
674                }
675                _ => {
676                    // Process successful response or non-recoverable error
677                    return match res.json::<RestDatesResponse>().await {
678                        Ok(response) => Ok(response.unique_dates()?),
679                        Err(e) => {
680                            msg_error!(Message::SiServerRestDatesParsingFailed(e.to_string()));
681                            Ok(HashSet::new()) // Return empty set on parsing error
682                        }
683                    };
684                }
685            }
686        }
687    }
688
689    /// Determines if the specified date is the last working day of its month.
690    ///
691    /// This utility function calculates whether a given date represents the final
692    /// working day in its month, which is useful for triggering monthly report
693    /// submissions and other end-of-month processing.
694    ///
695    /// ## Algorithm
696    ///
697    /// The calculation process:
698    /// 1. **Find Month End**: Determine the last calendar day of the month
699    /// 2. **Weekend Adjustment**: Move backward from weekends to find working days
700    /// 3. **Comparison**: Check if the input date matches the calculated last working day
701    ///
702    /// ## Limitations
703    ///
704    /// Currently only considers weekends (Saturday/Sunday) as non-working days.
705    /// Future versions may integrate with the rest dates API to consider holidays
706    /// and company-specific non-working days for more accurate calculations.
707    ///
708    /// # Arguments
709    ///
710    /// * `date` - The date to check against the last working day
711    ///
712    /// # Returns
713    ///
714    /// Returns `true` if the date is the last working day of its month,
715    /// `false` otherwise.
716    ///
717    /// # Errors
718    ///
719    /// Currently cannot fail, but returns `Result` for consistency and
720    /// future enhancement with holiday integration.
721    ///
722    /// # Examples
723    ///
724    /// ```rust,no_run
725    /// # use kasl::api::si::{Si, SiConfig};
726    /// # use chrono::NaiveDate;
727    /// # use anyhow::Result;
728    /// # fn example() -> Result<()> {
729    /// # let config = SiConfig {
730    /// #     login: "username".to_string(),
731    /// #     auth_url: "https://auth.company.com".to_string(),
732    /// #     api_url: "https://api.company.com".to_string(),
733    /// # };
734    /// let si = Si::new(&config);
735    /// let date = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); // January 31st
736    ///
737    /// if si.is_last_working_day_of_month(&date)? {
738    ///     println!("Time to submit monthly report!");
739    /// }
740    /// # Ok(())
741    /// # }
742    /// ```
743    pub fn is_last_working_day_of_month(&self, date: &NaiveDate) -> Result<bool> {
744        let (year, month) = (date.year(), date.month());
745
746        // Calculate the last day of the current month
747        let mut last_day_of_month = NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap().pred_opt().unwrap();
748
749        // Move backward from weekends to find the last working day
750        while matches!(last_day_of_month.weekday(), Weekday::Sat | Weekday::Sun) {
751            last_day_of_month -= Duration::days(1);
752        }
753
754        // Check if the input date matches the calculated last working day
755        Ok(date == &last_day_of_month)
756    }
757}
758
759/// Configuration for SiServer API integration.
760///
761/// This structure holds the necessary information for connecting to internal
762/// SiServer systems. Unlike other API integrations, SiServer requires separate
763/// authentication and API endpoints due to its sophisticated security architecture.
764///
765/// ## Multi-Endpoint Architecture
766///
767/// SiServer uses different endpoints for different purposes:
768/// - **Authentication URL**: LDAP authentication endpoint
769/// - **API URL**: Main API endpoint for reports and data
770/// - **Separation Benefits**: Enhanced security, load distribution, service isolation
771///
772/// ## Security Considerations
773///
774/// - Passwords are never stored in configuration files
775/// - Only username and endpoints are persisted
776/// - Session tokens are cached separately with encryption
777/// - Double base64 password encoding for transmission security
778#[derive(Serialize, Deserialize, Clone, Debug)]
779pub struct SiConfig {
780    /// Username for SiServer authentication.
781    ///
782    /// This should be the corporate username used for LDAP authentication.
783    /// Typically matches the username used for other company systems.
784    pub login: String,
785
786    /// URL for the SiServer authentication endpoint.
787    ///
788    /// This endpoint handles LDAP authentication and token generation.
789    /// Example: `https://auth.company.com`
790    ///
791    /// This is separate from the main API URL due to SiServer's security architecture.
792    pub auth_url: String,
793
794    /// Base URL for the main SiServer API endpoints.
795    ///
796    /// This endpoint handles report submission and data retrieval operations.
797    /// Example: `https://api.company.com`
798    ///
799    /// All API operations (reports, calendar) use this base URL.
800    pub api_url: String,
801}
802
803impl SiConfig {
804    /// Returns the configuration module metadata for SiServer.
805    ///
806    /// Used by the configuration system to identify and manage
807    /// SiServer-specific settings during interactive setup.
808    ///
809    /// # Returns
810    ///
811    /// A `ConfigModule` with SiServer identification information.
812    pub fn module() -> ConfigModule {
813        ConfigModule {
814            key: "si".to_string(),
815            name: "SiServer".to_string(),
816        }
817    }
818
819    /// Runs an interactive configuration setup for SiServer integration.
820    ///
821    /// Prompts the user for SiServer connection details including username
822    /// and both authentication and API endpoints. Uses existing configuration
823    /// values as defaults if available.
824    ///
825    /// ## Interactive Prompts
826    ///
827    /// 1. **Username**: Corporate username for LDAP authentication
828    /// 2. **Authentication URL**: LDAP endpoint for token generation
829    /// 3. **API URL**: Main API endpoint for reports and data operations
830    ///
831    /// All prompts show existing values as defaults if configuration already
832    /// exists, making it easy to update specific values without re-entering everything.
833    ///
834    /// ## Configuration Validation
835    ///
836    /// While this method doesn't validate actual connectivity, it provides
837    /// helpful prompts to guide users toward correct configuration values
838    /// for their corporate SiServer deployment.
839    ///
840    /// # Arguments
841    ///
842    /// * `config` - Existing SiServer configuration to use as defaults (if any)
843    ///
844    /// # Returns
845    ///
846    /// * `Result<Self>` - New SiServer configuration with user input
847    ///
848    /// # Errors
849    ///
850    /// Returns an error if:
851    /// - Terminal input/output fails
852    /// - User cancels the configuration process
853    /// - Input validation fails
854    ///
855    /// # Example
856    ///
857    /// ```rust,no_run
858    /// # use kasl::api::SiConfig;
859    /// # use anyhow::Result;
860    /// # fn example() -> Result<()> {
861    /// let existing_config = Some(SiConfig {
862    ///     login: "olduser".to_string(),
863    ///     auth_url: "https://old-auth.com".to_string(),
864    ///     api_url: "https://old-api.com".to_string(),
865    /// });
866    ///
867    /// let new_config = SiConfig::init(&existing_config)?;
868    /// # Ok(())
869    /// # }
870    /// ```
871    pub fn init(config: &Option<SiConfig>) -> Result<Self> {
872        // Use existing configuration as defaults, or create empty defaults
873        let config = config.clone().unwrap_or(Self {
874            login: "".to_string(),
875            auth_url: "".to_string(),
876            api_url: "".to_string(),
877        });
878
879        // Display configuration module header
880        msg_print!(Message::ConfigModuleSiServer);
881
882        // Interactive configuration with existing values as defaults
883        Ok(Self {
884            login: Input::with_theme(&ColorfulTheme::default())
885                .with_prompt("Enter your SiServer login")
886                .default(config.login)
887                .interact_text()?,
888            auth_url: Input::with_theme(&ColorfulTheme::default())
889                .with_prompt("Enter your SiServer login URL")
890                .default(config.auth_url)
891                .interact_text()?,
892            api_url: Input::with_theme(&ColorfulTheme::default())
893                .with_prompt("Enter the SiServer API URL")
894                .default(config.api_url)
895                .interact_text()?,
896        })
897    }
898}