Skip to main content

cargo_rail/config/
split.rs

1//! Split configuration - controls crate splitting and syncing behavior
2
3use crate::config::release::ChangelogConfig;
4use crate::config::unify::default_true;
5use crate::error::{ConfigError, RailError, RailResult};
6use serde::{Deserialize, Serialize};
7use std::path::PathBuf;
8
9/// Split configuration for a crate (under [crates.X.split])
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct CrateSplitConfig {
12  /// Remote repository URL or local path
13  pub remote: String,
14  /// Git branch to use
15  pub branch: String,
16  /// Split mode (single or combined)
17  pub mode: SplitMode,
18  /// For combined mode: how to structure the split repo
19  #[serde(default)]
20  pub workspace_mode: WorkspaceMode,
21  /// Crate paths to include in the split
22  #[serde(default)]
23  pub paths: Vec<CratePath>,
24  /// Additional files/directories to include
25  #[serde(default)]
26  pub include: Vec<String>,
27  /// Files/directories to exclude
28  #[serde(default)]
29  pub exclude: Vec<String>,
30}
31
32/// Full split configuration (flattened for command use)
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SplitConfig {
35  /// Crate name
36  pub name: String,
37  /// Remote repository URL or local path
38  pub remote: String,
39  /// Git branch to use
40  pub branch: String,
41  /// Split mode (single or combined)
42  pub mode: SplitMode,
43  /// For combined mode: how to structure the split repo
44  #[serde(default)]
45  pub workspace_mode: WorkspaceMode,
46  /// Crate paths to include in the split
47  #[serde(default)]
48  pub paths: Vec<CratePath>,
49  /// Additional files/directories to include
50  #[serde(default)]
51  pub include: Vec<String>,
52  /// Files/directories to exclude
53  #[serde(default)]
54  pub exclude: Vec<String>,
55
56  /// Release configuration: enable/disable publishing for this crate
57  #[serde(default = "default_true")]
58  pub publish: bool,
59
60  /// Per-crate changelog path override (default: CHANGELOG.md)
61  #[serde(default)]
62  pub changelog_path: Option<PathBuf>,
63}
64
65impl SplitConfig {
66  /// Get the path(s) for this split configuration
67  pub fn get_paths(&self) -> Vec<&PathBuf> {
68    self.paths.iter().map(|cp| &cp.path).collect()
69  }
70
71  /// Determine the target repository path for this split configuration
72  ///
73  /// For local paths (testing), returns the path as-is.
74  /// For remote URLs, extracts the repo name and places it adjacent to workspace root.
75  pub fn target_repo_path(&self, workspace_root: &std::path::Path) -> PathBuf {
76    if crate::utils::is_local_path(&self.remote) {
77      PathBuf::from(&self.remote)
78    } else {
79      let remote_name = self
80        .remote
81        .rsplit('/')
82        .next()
83        .unwrap_or(&self.name)
84        .trim_end_matches(".git");
85      workspace_root.join("..").join(remote_name)
86    }
87  }
88
89  /// Check if this split is using a local path (testing mode)
90  pub fn is_local_testing(&self) -> bool {
91    crate::utils::is_local_path(&self.remote)
92  }
93
94  /// Validate the split configuration
95  pub fn validate(&self) -> RailResult<()> {
96    // Check paths exist
97    if self.paths.is_empty() {
98      return Err(RailError::with_help(
99        format!("Split '{}' must have at least one crate path", self.name),
100        format!("Add paths in rail.toml under [crates.{}.split]", self.name),
101      ));
102    }
103
104    // Check remote is not empty
105    if self.remote.is_empty() {
106      return Err(RailError::Config(ConfigError::MissingField {
107        field: format!("remote for split '{}'", self.name),
108      }));
109    }
110
111    // Validate mode-specific requirements
112    match self.mode {
113      SplitMode::Single => {
114        if self.paths.len() != 1 {
115          return Err(RailError::with_help(
116            format!(
117              "Single mode split '{}' must have exactly one path (found {})",
118              self.name,
119              self.paths.len()
120            ),
121            "Change mode to 'combined' or remove extra paths",
122          ));
123        }
124      }
125      SplitMode::Combined => {
126        if self.paths.len() < 2 {
127          return Err(RailError::with_help(
128            format!(
129              "Combined mode split '{}' should have multiple paths (found {})",
130              self.name,
131              self.paths.len()
132            ),
133            "Change mode to 'single' or add more crate paths",
134          ));
135        }
136      }
137    }
138    Ok(())
139  }
140}
141
142/// Path to a crate in the workspace
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct CratePath {
145  /// Path to the crate directory
146  #[serde(rename = "crate")]
147  pub path: PathBuf,
148}
149
150/// Split mode: single crate or combined multi-crate
151#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
152#[serde(rename_all = "lowercase")]
153pub enum SplitMode {
154  /// Single crate per repository
155  #[default]
156  Single,
157  /// Multiple crates in one repository
158  Combined,
159}
160
161/// How to structure a combined split repository
162#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
163#[serde(rename_all = "lowercase")]
164pub enum WorkspaceMode {
165  /// Multiple standalone crates in one repo (no workspace structure)
166  #[default]
167  Standalone,
168  /// Workspace structure with root Cargo.toml (mirrors monorepo)
169  Workspace,
170}
171
172/// Per-crate sync configuration.
173///
174/// **Note:** Reserved for future use. Currently has no effect.
175/// Will hold sync-specific settings like conflict strategies, exclusion patterns, etc.
176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
177pub struct CrateSyncConfig {}
178
179/// Helper to build SplitConfig from crate name and CrateSplitConfig
180pub fn build_split_config(
181  name: String,
182  split: &CrateSplitConfig,
183  release_publish: Option<bool>,
184  changelog: Option<&ChangelogConfig>,
185) -> SplitConfig {
186  SplitConfig {
187    name,
188    remote: split.remote.clone(),
189    branch: split.branch.clone(),
190    mode: split.mode.clone(),
191    workspace_mode: split.workspace_mode.clone(),
192    paths: split.paths.clone(),
193    include: split.include.clone(),
194    exclude: split.exclude.clone(),
195    publish: release_publish.unwrap_or(true),
196    changelog_path: changelog.and_then(|c| c.path.clone()),
197  }
198}
199
200// Tests
201
202#[cfg(test)]
203mod tests {
204  use super::*;
205
206  #[test]
207  fn test_split_config_validate_empty_paths() {
208    let config = SplitConfig {
209      name: "test-crate".to_string(),
210      remote: "git@github.com:user/test.git".to_string(),
211      branch: "main".to_string(),
212      mode: SplitMode::Single,
213      workspace_mode: WorkspaceMode::default(),
214      paths: vec![],
215      include: vec![],
216      exclude: vec![],
217      publish: true,
218      changelog_path: None,
219    };
220
221    let result = config.validate();
222    assert!(result.is_err());
223    let err_msg = result.unwrap_err().to_string();
224    assert!(err_msg.contains("at least one crate path"));
225  }
226
227  #[test]
228  fn test_split_config_validate_empty_remote() {
229    let config = SplitConfig {
230      name: "test-crate".to_string(),
231      remote: "".to_string(),
232      branch: "main".to_string(),
233      mode: SplitMode::Single,
234      workspace_mode: WorkspaceMode::default(),
235      paths: vec![CratePath {
236        path: PathBuf::from("crates/test"),
237      }],
238      include: vec![],
239      exclude: vec![],
240      publish: true,
241      changelog_path: None,
242    };
243
244    let result = config.validate();
245    assert!(result.is_err());
246    let err_msg = result.unwrap_err().to_string();
247    assert!(err_msg.contains("remote"));
248  }
249
250  #[test]
251  fn test_split_config_validate_single_mode_multiple_paths() {
252    let config = SplitConfig {
253      name: "test-crate".to_string(),
254      remote: "git@github.com:user/test.git".to_string(),
255      branch: "main".to_string(),
256      mode: SplitMode::Single,
257      workspace_mode: WorkspaceMode::default(),
258      paths: vec![
259        CratePath {
260          path: PathBuf::from("crates/a"),
261        },
262        CratePath {
263          path: PathBuf::from("crates/b"),
264        },
265      ],
266      include: vec![],
267      exclude: vec![],
268      publish: true,
269      changelog_path: None,
270    };
271
272    let result = config.validate();
273    assert!(result.is_err());
274    let err_msg = result.unwrap_err().to_string();
275    assert!(err_msg.contains("Single mode"));
276    assert!(err_msg.contains("exactly one path"));
277  }
278
279  #[test]
280  fn test_split_config_validate_combined_mode_single_path() {
281    let config = SplitConfig {
282      name: "test-crate".to_string(),
283      remote: "git@github.com:user/test.git".to_string(),
284      branch: "main".to_string(),
285      mode: SplitMode::Combined,
286      workspace_mode: WorkspaceMode::default(),
287      paths: vec![CratePath {
288        path: PathBuf::from("crates/a"),
289      }],
290      include: vec![],
291      exclude: vec![],
292      publish: true,
293      changelog_path: None,
294    };
295
296    let result = config.validate();
297    assert!(result.is_err());
298    let err_msg = result.unwrap_err().to_string();
299    assert!(err_msg.contains("Combined mode"));
300    assert!(err_msg.contains("multiple paths"));
301  }
302
303  #[test]
304  fn test_split_config_validate_valid() {
305    let config = SplitConfig {
306      name: "test-crate".to_string(),
307      remote: "git@github.com:user/test.git".to_string(),
308      branch: "main".to_string(),
309      mode: SplitMode::Single,
310      workspace_mode: WorkspaceMode::default(),
311      paths: vec![CratePath {
312        path: PathBuf::from("crates/test"),
313      }],
314      include: vec![],
315      exclude: vec![],
316      publish: true,
317      changelog_path: None,
318    };
319
320    assert!(config.validate().is_ok());
321  }
322}