env_sync/
sync.rs

1//! Environment file synchronization functionality.
2//!
3//! This module provides functionality to synchronize local environment files
4//! with template files, preserving local values and comments while adopting
5//! the template structure.
6//!
7//! # Sync Logic
8//!
9//! The sync process:
10//! 1. Takes the template file as the base structure
11//! 2. For each variable in the template:
12//!    - If template value is empty and local has a value, use local value
13//!    - If template has no inline comment but local does, copy local comment
14//!    - If template has no preceding comments but local does, copy local comments
15//! 3. Writes the result back to the local file
16//!
17//! # Examples
18//!
19//! ```rust,no_run
20//! use env_sync::sync::{EnvSync, EnvSyncOptions};
21//! use std::path::PathBuf;
22//!
23//! let options = EnvSyncOptions {
24//!     local_file: Some(PathBuf::from(".env")),
25//!     template_file: PathBuf::from(".env.template"),
26//! };
27//!
28//! EnvSync::sync_with_options(options).unwrap();
29//! ```
30
31use std::path::{Path, PathBuf};
32
33#[cfg(feature = "tracing")]
34use tracing::{debug, info, trace};
35
36use crate::parse::{EnvEntry, EnvFile, ParseError};
37
38const DEFAULT_LOCAL_FILENAME: &str = ".env";
39
40/// Main synchronization service for environment files.
41pub struct EnvSync;
42
43impl EnvSync {
44  /// Synchronizes environment files using the provided options.
45  ///
46  /// Creates the local file if it doesn't exist. Returns an error if the template file doesn't exist.
47  pub fn sync_with_options(options: EnvSyncOptions) -> Result<(), EnvSyncError> {
48    #[cfg(feature = "tracing")]
49    info!("Starting env sync");
50
51    let EnvSyncOptions {
52      local_file,
53      template_file,
54    } = options;
55
56    let local_path = local_file.unwrap_or_else(|| {
57      std::env::current_dir()
58        .unwrap_or_else(|_| PathBuf::from("."))
59        .join(DEFAULT_LOCAL_FILENAME)
60    });
61
62    #[cfg(feature = "tracing")]
63    debug!(?local_path, ?template_file, "Resolved file paths");
64
65    if !template_file.exists() {
66      return Err(EnvSyncError::TemplateNotFound(template_file));
67    }
68
69    if !local_path.exists() {
70      #[cfg(feature = "tracing")]
71      debug!("Creating local file: {:?}", local_path);
72      std::fs::write(&local_path, "").map_err(EnvSyncError::CreateLocal)?;
73    }
74
75    let local_str = std::fs::read_to_string(&local_path).map_err(EnvSyncError::LocalIo)?;
76    let template_str = std::fs::read_to_string(&template_file).map_err(EnvSyncError::TemplateIo)?;
77
78    let local_content = local_str
79      .as_str()
80      .try_into()
81      .map_err(EnvSyncError::LocalParse)?;
82
83    let template_content = template_str
84      .as_str()
85      .try_into()
86      .map_err(EnvSyncError::TemplateParse)?;
87
88    let synced = Self::sync(local_content, template_content)?;
89
90    Self::update_local(synced, local_path)
91  }
92
93  /// Performs the core synchronization logic between local and template files.
94  ///
95  /// Takes the template as the base structure and enriches it with local values and comments.
96  fn sync<'a>(local: EnvFile<'a>, mut template: EnvFile<'a>) -> Result<EnvFile<'a>, EnvSyncError> {
97    #[cfg(feature = "tracing")]
98    debug!(
99      "Starting sync of {} template entries",
100      template.entries.len()
101    );
102
103    for entry in &mut template.entries {
104      if let EnvEntry::Variable(template_var) = entry
105        && let Some(local_var) = local.get(&template_var.key)
106      {
107        #[cfg(feature = "tracing")]
108        trace!("Processing variable: {}", template_var.key);
109
110        // Copy value if template is empty
111        if template_var.value.is_empty() && !local_var.value.is_empty() {
112          #[cfg(feature = "tracing")]
113          trace!(
114            "Copying local value for {}: {}",
115            template_var.key, local_var.value
116          );
117          template_var.value = local_var.value.clone();
118        }
119
120        // Copy inline comment if template doesn't have one
121        if template_var.inline_comment.is_none() && local_var.inline_comment.is_some() {
122          #[cfg(feature = "tracing")]
123          trace!("Copying inline comment for {}", template_var.key);
124          template_var.inline_comment = local_var.inline_comment.clone();
125        }
126
127        // Copy preceding comments if template doesn't have any
128        if template_var.preceding_comments.is_empty() && !local_var.preceding_comments.is_empty() {
129          #[cfg(feature = "tracing")]
130          trace!(
131            "Copying {} preceding comments for {}",
132            local_var.preceding_comments.len(),
133            template_var.key
134          );
135          template_var.preceding_comments = local_var.preceding_comments.clone();
136        }
137      }
138    }
139
140    Ok(template)
141  }
142
143  /// Writes the synchronized content back to the local file.
144  fn update_local<P: AsRef<Path>>(local: EnvFile, local_path: P) -> Result<(), EnvSyncError> {
145    #[cfg(feature = "tracing")]
146    debug!("Writing synced content to {:?}", local_path.as_ref());
147
148    let content = local.to_string();
149    std::fs::write(local_path, content).map_err(EnvSyncError::Write)?;
150
151    #[cfg(feature = "tracing")]
152    info!("Sync completed successfully");
153
154    Ok(())
155  }
156}
157
158/// Errors that can occur during environment file synchronization.
159#[derive(Debug, thiserror::Error)]
160pub enum EnvSyncError {
161  /// Error reading the local environment file
162  #[error("Local file IO error: {0}")]
163  LocalIo(std::io::Error),
164  /// Error parsing the local environment file
165  #[error("Local file parse error: {0}")]
166  LocalParse(ParseError),
167  /// Error reading the template file
168  #[error("Template file IO error: {0}")]
169  TemplateIo(std::io::Error),
170  /// Error parsing the template file
171  #[error("Template file parse error: {0}")]
172  TemplateParse(ParseError),
173  /// Error writing the synchronized content
174  #[error("Write error: {0}")]
175  Write(std::io::Error),
176  /// Error creating the local file
177  #[error("Failed to create local file: {0}")]
178  CreateLocal(std::io::Error),
179  /// Template file does not exist
180  #[error("Template file not found: {0}")]
181  TemplateNotFound(PathBuf),
182}
183
184/// Configuration options for environment file synchronization.
185pub struct EnvSyncOptions {
186  /// Path to the local environment file. If None, defaults to `.env` in current directory.
187  pub local_file: Option<PathBuf>,
188  /// Path to the template file that defines the desired structure.
189  pub template_file: PathBuf,
190}
191
192#[cfg(test)]
193mod tests {
194  use super::*;
195
196  #[test]
197  fn test_sync() {
198    let local_content = "# Comment for KEY1\nKEY1=value1\nKEY2=value2 # inline comment\nKEY3=";
199    let template_content = "KEY1=\nKEY2=template_value\nKEY3=template_value3\nKEY4=new_key";
200
201    let local: EnvFile = local_content.try_into().unwrap();
202    let template: EnvFile = template_content.try_into().unwrap();
203
204    let synced = EnvSync::sync(local, template).unwrap();
205
206    let key1 = synced.get("KEY1").unwrap();
207    assert_eq!(key1.value, "value1");
208    assert_eq!(key1.preceding_comments.len(), 1);
209
210    let key2 = synced.get("KEY2").unwrap();
211    assert_eq!(key2.value, "template_value");
212    assert_eq!(
213      key2.inline_comment.as_ref().unwrap().to_string(),
214      "# inline comment"
215    );
216
217    assert_eq!(synced.get("KEY3").unwrap().value, "template_value3");
218    assert_eq!(synced.get("KEY4").unwrap().value, "new_key");
219  }
220
221  #[test]
222  fn test_template_not_found() {
223    use std::path::PathBuf;
224
225    let options = EnvSyncOptions {
226      local_file: None,
227      template_file: PathBuf::from("nonexistent.env.template"),
228    };
229
230    let result = EnvSync::sync_with_options(options);
231    assert!(result.is_err());
232
233    match result.unwrap_err() {
234      EnvSyncError::TemplateNotFound(path) => {
235        assert_eq!(path, PathBuf::from("nonexistent.env.template"));
236      }
237      _ => panic!("Expected TemplateNotFound error"),
238    }
239  }
240}