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