1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
//! File staging and platform-specific logic.
//!
//! This module handles copying built libraries to the target directory with
//! the toolchain-specific naming convention required by Dylint.
use crate::builder::{BuildResult, library_extension, library_prefix};
use crate::crate_name::CrateName;
use crate::error::{InstallerError, Result};
use camino::{Utf8Path, Utf8PathBuf};
use std::fs;
/// Handles staging of built libraries to the target directory.
pub struct Stager {
target_dir: Utf8PathBuf,
toolchain: String,
}
impl Stager {
/// Create a new stager with the given target directory and toolchain.
#[must_use]
pub fn new(target_dir: Utf8PathBuf, toolchain: &str) -> Self {
Self {
target_dir,
toolchain: toolchain.to_owned(),
}
}
/// Ensure the target directory exists and is writable.
///
/// # Errors
///
/// Returns an error if the directory cannot be created or is not writable.
pub fn prepare(&self) -> Result<()> {
let staging_dir = self.staging_path();
fs::create_dir_all(&staging_dir)?;
// Verify writability by attempting to create a temp file
let test_path = staging_dir.join(".whitaker-installer-test");
match fs::write(&test_path, b"test") {
Ok(()) => {
let _ = fs::remove_file(&test_path);
Ok(())
}
Err(e) => Err(InstallerError::TargetNotWritable {
path: staging_dir,
reason: e.to_string(),
}),
}
}
/// Stage a built library to the target directory.
///
/// The library is copied with the toolchain suffix in its filename,
/// following the Dylint naming convention.
///
/// # Errors
///
/// Returns an error if the copy operation fails.
pub fn stage(&self, build_result: &BuildResult) -> Result<Utf8PathBuf> {
let staged_name = self.staged_filename(&build_result.crate_name);
let dest_path = self.staging_path().join(&staged_name);
fs::copy(&build_result.library_path, &dest_path).map_err(|e| {
InstallerError::StagingFailed {
reason: format!(
"failed to copy {} to {}: {e}",
build_result.library_path, dest_path
),
}
})?;
Ok(dest_path)
}
/// Stage all built libraries.
///
/// # Errors
///
/// Returns an error if any staging operation fails.
pub fn stage_all(&self, build_results: &[BuildResult]) -> Result<Vec<Utf8PathBuf>> {
build_results.iter().map(|r| self.stage(r)).collect()
}
/// Return the full path to the staging directory.
#[must_use]
pub fn staging_path(&self) -> Utf8PathBuf {
self.target_dir.join(&self.toolchain).join("release")
}
/// Return the target directory root.
#[must_use]
pub fn target_dir(&self) -> &Utf8Path {
&self.target_dir
}
/// Compute the staged filename with toolchain suffix.
///
/// The filename follows the Dylint naming convention:
/// `{prefix}{crate_name}@{toolchain}{extension}`
///
/// Where:
/// - `prefix` is platform-specific (`lib` on Unix, empty on Windows)
/// - `crate_name` has hyphens replaced with underscores
/// - `toolchain` is the Rust toolchain channel
/// - `extension` is platform-specific (`.so`, `.dylib`, or `.dll`)
#[must_use]
pub fn staged_filename(&self, crate_name: &CrateName) -> String {
let base_name = crate_name.as_str().replace('-', "_");
format!(
"{}{}@{}{}",
library_prefix(),
base_name,
self.toolchain,
library_extension()
)
}
}
/// Return the default staging directory for the current platform.
///
/// Uses the `directories-next` crate to locate the platform-specific local data
/// directory. This function creates a `directories_next::BaseDirs` instance and
/// calls its `data_local_dir()` method to obtain the base path (for example,
/// `~/.local/share` on many Linux distributions, `~/Library/Application Support`
/// on macOS, and the Local AppData directory on Windows). The installer appends
/// `dylint/lib` under that directory.
#[must_use]
pub fn default_target_dir() -> Option<Utf8PathBuf> {
directories_next::BaseDirs::new()
.map(|dirs| dirs.data_local_dir().to_owned())
.and_then(|p| Utf8PathBuf::try_from(p).ok())
.map(|p| p.join("dylint").join("lib"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staged_filename_includes_toolchain() {
let stager = Stager::new(Utf8PathBuf::from("/tmp/test"), "nightly-2026-05-28");
let crate_name = CrateName::from("module_max_lines");
let filename = stager.staged_filename(&crate_name);
assert!(filename.contains("nightly-2026-05-28"));
assert!(filename.contains("module_max_lines"));
}
#[test]
fn staging_path_includes_toolchain_and_release() {
let stager = Stager::new(
Utf8PathBuf::from("/home/user/.local/share/dylint/lib"),
"nightly-2026-05-28",
);
let path = stager.staging_path();
// Use ends_with for platform-independent path checking (compares components,
// not strings, so it works with both / and \ separators)
assert!(path.ends_with("nightly-2026-05-28/release"));
assert!(path.as_str().contains("dylint"));
assert!(path.as_str().contains("lib"));
}
#[test]
fn default_target_dir_is_some() {
// Skip assertion in environments without a home directory (e.g., CI containers)
let Some(d) = default_target_dir() else {
return;
};
assert!(d.as_str().contains("dylint"));
}
}