Skip to main content

canic_host/install_root/
state.rs

1use crate::release_set::dfx_root;
2use serde::{Deserialize, Serialize};
3use std::{fs, path::Path, path::PathBuf};
4
5pub(super) const INSTALL_STATE_SCHEMA_VERSION: u32 = 1;
6const INSTALL_STATE_FILE: &str = "install-state.json";
7pub const DEFAULT_FLEET_NAME: &str = "default";
8const CURRENT_FLEET_FILE: &str = "current-fleet";
9
10///
11/// InstallState
12///
13
14#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
15pub struct InstallState {
16    pub schema_version: u32,
17    #[serde(default = "default_fleet_name")]
18    pub fleet: String,
19    pub installed_at_unix_secs: u64,
20    pub network: String,
21    pub root_target: String,
22    pub root_canister_id: String,
23    pub root_build_target: String,
24    pub workspace_root: String,
25    pub dfx_root: String,
26    pub config_path: String,
27    pub release_set_manifest_path: String,
28}
29
30///
31/// FleetSummary
32///
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct FleetSummary {
36    pub name: String,
37    pub current: bool,
38    pub state: InstallState,
39}
40
41/// Read the persisted install state for one project/network when present.
42pub(super) fn read_install_state(
43    dfx_root: &Path,
44    network: &str,
45) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
46    if let Some(fleet) = read_selected_fleet_name(dfx_root, network)? {
47        return read_fleet_install_state(dfx_root, network, &fleet);
48    }
49
50    read_legacy_install_state(dfx_root, network)
51}
52
53/// Read a named fleet install state for one project/network when present.
54pub(super) fn read_fleet_install_state(
55    dfx_root: &Path,
56    network: &str,
57    fleet: &str,
58) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
59    validate_fleet_name(fleet)?;
60    let path = fleet_install_state_path(dfx_root, network, fleet);
61    if !path.is_file() {
62        return Ok(None);
63    }
64
65    let bytes = fs::read(&path)?;
66    let mut state: InstallState = serde_json::from_slice(&bytes)?;
67    if state.fleet.is_empty() {
68        state.fleet = fleet.to_string();
69    }
70    Ok(Some(state))
71}
72
73/// Read the install state for the discovered current project/network.
74pub fn read_current_install_state(
75    network: &str,
76) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
77    let dfx_root = dfx_root()?;
78    read_install_state(&dfx_root, network)
79}
80
81/// Read either a named fleet state or the selected current fleet state.
82pub fn read_current_or_fleet_install_state(
83    network: &str,
84    fleet: Option<&str>,
85) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
86    let dfx_root = dfx_root()?;
87    match fleet {
88        Some(fleet) => read_fleet_install_state(&dfx_root, network, fleet),
89        None => read_install_state(&dfx_root, network),
90    }
91}
92
93/// List installed fleets for the current project/network.
94pub fn list_current_fleets(network: &str) -> Result<Vec<FleetSummary>, Box<dyn std::error::Error>> {
95    let dfx_root = dfx_root()?;
96    list_fleets(&dfx_root, network)
97}
98
99/// List installed fleets for one project/network.
100pub(super) fn list_fleets(
101    dfx_root: &Path,
102    network: &str,
103) -> Result<Vec<FleetSummary>, Box<dyn std::error::Error>> {
104    let current = read_selected_fleet_name(dfx_root, network)?;
105    let mut fleets = Vec::new();
106    let dir = fleets_dir(dfx_root, network);
107    if dir.is_dir() {
108        for entry in fs::read_dir(&dir)? {
109            let entry = entry?;
110            let path = entry.path();
111            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
112                continue;
113            }
114            let Some(name) = path.file_stem().and_then(|stem| stem.to_str()) else {
115                continue;
116            };
117            if let Some(state) = read_fleet_install_state(dfx_root, network, name)? {
118                fleets.push(FleetSummary {
119                    name: name.to_string(),
120                    current: current.as_deref() == Some(name),
121                    state,
122                });
123            }
124        }
125    }
126
127    if fleets.is_empty()
128        && let Some(state) = read_legacy_install_state(dfx_root, network)?
129    {
130        fleets.push(FleetSummary {
131            name: state.fleet.clone(),
132            current: true,
133            state,
134        });
135    }
136
137    fleets.sort_by(|left, right| left.name.cmp(&right.name));
138    Ok(fleets)
139}
140
141/// Select one installed fleet as the current project/network default.
142pub fn select_current_fleet(
143    network: &str,
144    fleet: &str,
145) -> Result<InstallState, Box<dyn std::error::Error>> {
146    let dfx_root = dfx_root()?;
147    select_fleet(&dfx_root, network, fleet)
148}
149
150/// Select one installed fleet for one project/network.
151fn select_fleet(
152    dfx_root: &Path,
153    network: &str,
154    fleet: &str,
155) -> Result<InstallState, Box<dyn std::error::Error>> {
156    let Some(state) = read_fleet_install_state(dfx_root, network, fleet)?.or_else(|| {
157        matching_legacy_fleet_state(dfx_root, network, fleet)
158            .ok()
159            .flatten()
160    }) else {
161        return Err(format!("unknown fleet {fleet} on network {network}").into());
162    };
163    if fleet_install_state_path(dfx_root, network, fleet).is_file() {
164        write_current_fleet_name(dfx_root, network, fleet)?;
165    } else {
166        write_install_state(dfx_root, network, &state)?;
167    }
168    Ok(state)
169}
170
171/// Return the legacy project-local install state path for one network.
172#[must_use]
173fn install_state_path(dfx_root: &Path, network: &str) -> PathBuf {
174    dfx_root
175        .join(".canic")
176        .join(network)
177        .join(INSTALL_STATE_FILE)
178}
179
180/// Return the project-local state path for one named fleet.
181#[must_use]
182pub(super) fn fleet_install_state_path(dfx_root: &Path, network: &str, fleet: &str) -> PathBuf {
183    fleets_dir(dfx_root, network).join(format!("{fleet}.json"))
184}
185
186/// Return the project-local current-fleet pointer path for one network.
187#[must_use]
188pub(super) fn current_fleet_path(dfx_root: &Path, network: &str) -> PathBuf {
189    dfx_root
190        .join(".canic")
191        .join(network)
192        .join(CURRENT_FLEET_FILE)
193}
194
195// Return the directory that owns named fleet state files.
196fn fleets_dir(dfx_root: &Path, network: &str) -> PathBuf {
197    dfx_root.join(".canic").join(network).join("fleets")
198}
199
200// Persist the completed install state under the project-local `.canic` directory.
201pub(super) fn write_install_state(
202    dfx_root: &Path,
203    network: &str,
204    state: &InstallState,
205) -> Result<PathBuf, Box<dyn std::error::Error>> {
206    validate_fleet_name(&state.fleet)?;
207    let path = fleet_install_state_path(dfx_root, network, &state.fleet);
208    if let Some(parent) = path.parent() {
209        fs::create_dir_all(parent)?;
210    }
211    fs::write(&path, serde_json::to_vec_pretty(state)?)?;
212    write_current_fleet_name(dfx_root, network, &state.fleet)?;
213    Ok(path)
214}
215
216// Read a legacy single-slot install state when no named fleet pointer exists.
217fn read_legacy_install_state(
218    dfx_root: &Path,
219    network: &str,
220) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
221    let path = install_state_path(dfx_root, network);
222    if !path.is_file() {
223        return Ok(None);
224    }
225
226    let bytes = fs::read(&path)?;
227    let mut state: InstallState = serde_json::from_slice(&bytes)?;
228    if state.fleet.is_empty() {
229        state.fleet = DEFAULT_FLEET_NAME.to_string();
230    }
231    Ok(Some(state))
232}
233
234// Return the legacy single-slot state only when it matches the requested fleet.
235fn matching_legacy_fleet_state(
236    dfx_root: &Path,
237    network: &str,
238    fleet: &str,
239) -> Result<Option<InstallState>, Box<dyn std::error::Error>> {
240    Ok(read_legacy_install_state(dfx_root, network)?.filter(|state| state.fleet == fleet))
241}
242
243// Read the selected fleet name for one project/network.
244fn read_selected_fleet_name(
245    dfx_root: &Path,
246    network: &str,
247) -> Result<Option<String>, Box<dyn std::error::Error>> {
248    let path = current_fleet_path(dfx_root, network);
249    if !path.is_file() {
250        return Ok(None);
251    }
252
253    let name = fs::read_to_string(path)?.trim().to_string();
254    validate_fleet_name(&name)?;
255    Ok(Some(name))
256}
257
258// Write the selected fleet name for one project/network.
259fn write_current_fleet_name(
260    dfx_root: &Path,
261    network: &str,
262    fleet: &str,
263) -> Result<(), Box<dyn std::error::Error>> {
264    validate_fleet_name(fleet)?;
265    let path = current_fleet_path(dfx_root, network);
266    if let Some(parent) = path.parent() {
267        fs::create_dir_all(parent)?;
268    }
269    fs::write(path, format!("{fleet}\n"))?;
270    Ok(())
271}
272
273// Return the serde default for legacy install-state records.
274fn default_fleet_name() -> String {
275    DEFAULT_FLEET_NAME.to_string()
276}
277
278// Keep fleet names filesystem-safe and easy to type in commands.
279pub(super) fn validate_fleet_name(name: &str) -> Result<(), Box<dyn std::error::Error>> {
280    let valid = !name.is_empty()
281        && name
282            .bytes()
283            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'));
284    if valid {
285        Ok(())
286    } else {
287        Err(format!("invalid fleet name {name:?}; use letters, numbers, '-' or '_'").into())
288    }
289}