Skip to main content

start_command/
execution_identity.rs

1//! Names an execution answers to (issue #231).
2//!
3//! An execution is found by its UUID, its backend session name, the session
4//! names it used before a resume (`options.sessionNameHistory`, #162), its
5//! display name (`options.name`, set by `--rename`) and its aliases
6//! (`options.aliases`, added by `--alias` and `--rename`). Aliases are kept
7//! apart from `sessionNameHistory` because that list names real backend
8//! sessions: `--cleanup` removes those containers and `--resume` derives the
9//! root session from it.
10use crate::execution_store::{ExecutionRecord, ExecutionStore};
11use serde_json::{json, Value};
12
13fn option_str<'a>(record: &'a ExecutionRecord, key: &str) -> Option<&'a str> {
14    record.options.get(key).and_then(Value::as_str)
15}
16
17fn option_list<'a>(record: &'a ExecutionRecord, key: &str) -> Vec<&'a str> {
18    record
19        .options
20        .get(key)
21        .and_then(Value::as_array)
22        .map(|items| items.iter().filter_map(Value::as_str).collect())
23        .unwrap_or_default()
24}
25
26fn has_name(record: &ExecutionRecord, identifier: &str) -> bool {
27    option_str(record, "name") == Some(identifier)
28        || option_list(record, "aliases").contains(&identifier)
29}
30
31/// Resolve an identifier among stored records: UUID, then current session
32/// name, then earlier session names, then display name and aliases.
33pub fn find_record<'a>(
34    records: &'a [ExecutionRecord],
35    identifier: &str,
36) -> Option<&'a ExecutionRecord> {
37    if identifier.is_empty() {
38        return None;
39    }
40    records
41        .iter()
42        .find(|r| r.uuid == identifier)
43        .or_else(|| {
44            records
45                .iter()
46                .find(|r| option_str(r, "sessionName") == Some(identifier))
47        })
48        .or_else(|| {
49            records
50                .iter()
51                .find(|r| option_list(r, "sessionNameHistory").contains(&identifier))
52        })
53        .or_else(|| records.iter().find(|r| has_name(r, identifier)))
54}
55
56/// Every name the record answers to, UUID first, without duplicates.
57pub fn execution_aliases(record: &ExecutionRecord) -> Vec<String> {
58    let mut names: Vec<String> = Vec::new();
59    let candidates = std::iter::once(Some(record.uuid.as_str()))
60        .chain(
61            option_list(record, "sessionNameHistory")
62                .into_iter()
63                .map(Some),
64        )
65        .chain(std::iter::once(option_str(record, "sessionName")))
66        .chain(option_list(record, "aliases").into_iter().map(Some))
67        .chain(std::iter::once(option_str(record, "name")));
68    for name in candidates.flatten() {
69        if !name.is_empty() && !names.iter().any(|n| n == name) {
70            names.push(name.to_string());
71        }
72    }
73    names
74}
75
76/// True once the record has a caller-given name (`--alias`/`--rename`).
77pub fn has_user_names(record: &ExecutionRecord) -> bool {
78    option_str(record, "name").is_some_and(|n| !n.is_empty())
79        || !option_list(record, "aliases").is_empty()
80}
81
82fn validate_name(name: &str) -> Result<(), String> {
83    if name.trim().is_empty() {
84        return Err("The new name must not be empty".into());
85    }
86    if name.starts_with('-') {
87        return Err(format!(
88            "Invalid name \"{}\": names cannot start with \"-\"",
89            name
90        ));
91    }
92    Ok(())
93}
94
95/// Add a name to an execution (`$ --alias <id> <name>`) or rename it
96/// (`$ --rename <id> <name>`, which keeps the previous display name as an
97/// alias). The backend session/container keeps its name; only lookup and the
98/// displayed name change. Returns the output to print, or the error.
99pub fn name_execution(
100    store: Option<&ExecutionStore>,
101    identifier: &str,
102    name: &str,
103    rename: bool,
104) -> Result<String, String> {
105    let store = store.ok_or("Execution tracking is disabled.")?;
106    validate_name(name)?;
107    let changed = store.with_lock(|records| {
108        let target = find_record(records, identifier)
109            .ok_or_else(|| {
110                format!(
111                    "No execution found with UUID or session name: {}",
112                    identifier
113                )
114            })?
115            .uuid
116            .clone();
117        let owner = find_record(records, name).map(|r| r.uuid.clone());
118        if let Some(owner) = owner.as_ref().filter(|o| **o != target) {
119            return Err(format!(
120                "Name \"{}\" already refers to execution {}",
121                name, owner
122            ));
123        }
124        let record = records.iter_mut().find(|r| r.uuid == target).unwrap();
125        let mut aliases: Vec<String> = option_list(record, "aliases")
126            .into_iter()
127            .map(str::to_string)
128            .collect();
129        if rename {
130            let current_name = option_str(record, "name").map(str::to_string);
131            let previous = current_name
132                .clone()
133                .or_else(|| option_str(record, "sessionName").map(str::to_string))
134                .unwrap_or_else(|| record.uuid.clone());
135            if previous == name {
136                return Ok(None);
137            }
138            // A session name or UUID still resolves on its own; an earlier
139            // display name has to become an alias to keep resolving.
140            if current_name.is_some() {
141                aliases.push(previous);
142            }
143            record.options.insert("name".into(), json!(name));
144        } else if owner.is_some() {
145            return Ok(None);
146        } else {
147            aliases.push(name.to_string());
148        }
149        let display = option_str(record, "name").map(str::to_string);
150        let mut unique: Vec<String> = Vec::new();
151        for alias in aliases {
152            if Some(&alias) != display.as_ref() && !unique.contains(&alias) {
153                unique.push(alias);
154            }
155        }
156        if unique.is_empty() {
157            record.options.remove("aliases");
158        } else {
159            record.options.insert("aliases".into(), json!(unique));
160        }
161        Ok(Some(record.clone()))
162    })?;
163    let unchanged = changed.is_none();
164    let current = match changed {
165        Some(record) => record,
166        None => store.get(identifier).ok_or_else(|| {
167            format!(
168                "No execution found with UUID or session name: {}",
169                identifier
170            )
171        })?,
172    };
173    let action = match (unchanged, rename) {
174        (true, _) => "Unchanged",
175        (false, true) => "Renamed",
176        (false, false) => "Aliased",
177    };
178    Ok(format!(
179        "{} execution {}\naliases: [{}]",
180        action,
181        current.uuid,
182        execution_aliases(&current).join(", ")
183    ))
184}