Skip to main content

objectiveai_cli/filesystem/
publish.rs

1use std::path::{Path, PathBuf};
2
3use super::{Client, Error};
4
5/// The kind of resource being published.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum Kind {
8    Agents,
9    Swarms,
10    Functions,
11    Profiles,
12}
13
14impl Kind {
15    pub fn as_str(self) -> &'static str {
16        match self {
17            Kind::Agents => "agents",
18            Kind::Swarms => "swarms",
19            Kind::Functions => "functions",
20            Kind::Profiles => "profiles",
21        }
22    }
23
24    /// The filename used for this kind's primary JSON file.
25    pub fn filename(self) -> &'static str {
26        match self {
27            Kind::Agents => "agent.json",
28            Kind::Swarms => "swarm.json",
29            Kind::Functions => "function.json",
30            Kind::Profiles => "profile.json",
31        }
32    }
33}
34
35fn validate_repository_name(name: &str) -> Result<(), Error> {
36    if name.is_empty() || name.len() > 100 {
37        return Err(Error::InvalidRepositoryName(name.to_string()));
38    }
39    if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
40        return Err(Error::InvalidRepositoryName(name.to_string()));
41    }
42    Ok(())
43}
44
45fn repo_path(client: &Client, kind: Kind, repository: &str) -> PathBuf {
46    client
47        .state_dir()
48        .join(kind.as_str())
49        .join(&client.commit_author_name)
50        .join(repository)
51}
52
53/// Publishes an agent to the local filesystem git repository.
54pub async fn publish_agent(
55    client: &Client,
56    repository: &str,
57    agent: &objectiveai_sdk::agent::RemoteAgentBaseWithFallbacks,
58    message: &str,
59    overwrite: bool,
60) -> Result<String, Error> {
61    let content =
62        serde_json::to_string_pretty(agent).map_err(Error::Serialize)?;
63    publish(
64        client,
65        Kind::Agents,
66        repository,
67        &content,
68        message,
69        overwrite,
70    )
71    .await
72}
73
74/// Publishes a swarm to the local filesystem git repository.
75pub async fn publish_swarm(
76    client: &Client,
77    repository: &str,
78    swarm: &objectiveai_sdk::swarm::RemoteSwarmBase,
79    message: &str,
80    overwrite: bool,
81) -> Result<String, Error> {
82    let content =
83        serde_json::to_string_pretty(swarm).map_err(Error::Serialize)?;
84    publish(
85        client,
86        Kind::Swarms,
87        repository,
88        &content,
89        message,
90        overwrite,
91    )
92    .await
93}
94
95/// Publishes a function to the local filesystem git repository.
96pub async fn publish_function(
97    client: &Client,
98    repository: &str,
99    function: &objectiveai_sdk::functions::FullRemoteFunction,
100    message: &str,
101    overwrite: bool,
102) -> Result<String, Error> {
103    let content =
104        serde_json::to_string_pretty(function).map_err(Error::Serialize)?;
105    publish(
106        client,
107        Kind::Functions,
108        repository,
109        &content,
110        message,
111        overwrite,
112    )
113    .await
114}
115
116/// Publishes a profile to the local filesystem git repository.
117pub async fn publish_profile(
118    client: &Client,
119    repository: &str,
120    profile: &objectiveai_sdk::functions::RemoteProfile,
121    message: &str,
122    overwrite: bool,
123) -> Result<String, Error> {
124    let content =
125        serde_json::to_string_pretty(profile).map_err(Error::Serialize)?;
126    publish(
127        client,
128        Kind::Profiles,
129        repository,
130        &content,
131        message,
132        overwrite,
133    )
134    .await
135}
136
137/// Core publish: writes content to a git repository, commits, returns the commit SHA.
138///
139/// If the repository already exists and the file already exists, `overwrite`
140/// must be true or an error is returned.
141async fn publish(
142    client: &Client,
143    kind: Kind,
144    repository: &str,
145    content: &str,
146    message: &str,
147    overwrite: bool,
148) -> Result<String, Error> {
149    validate_repository_name(repository)?;
150
151    let path = repo_path(client, kind, repository);
152    let filename = kind.filename();
153
154    tokio::fs::create_dir_all(&path).await?;
155
156    // Check overwrite guard before doing git work.
157    if !overwrite && tokio::fs::metadata(path.join(filename)).await.is_ok() {
158        return Err(Error::Write(
159            path.join(filename),
160            std::io::Error::new(
161                std::io::ErrorKind::AlreadyExists,
162                "file already exists (use overwrite to replace)",
163            ),
164        ));
165    }
166
167    // Git operations are synchronous — run on a blocking thread. The new
168    // file content is written INSIDE git_commit, after it resets the
169    // working tree to a clean HEAD. Writing it here (before that reset)
170    // is what made overwrite a no-op: the hard reset clobbered the new
171    // file, so the staged tree matched HEAD and no commit was created.
172    let commit_author_name = client.commit_author_name.clone();
173    let commit_author_email = client.commit_author_email.clone();
174    let message = message.to_string();
175    let filename = filename.to_string();
176    let content = content.to_string();
177
178    tokio::task::spawn_blocking(move || {
179        git_commit(
180            &path,
181            &filename,
182            &content,
183            &commit_author_name,
184            &commit_author_email,
185            &message,
186        )
187    })
188    .await
189    .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?
190}
191
192/// Opens or initializes the git repo, writes `content` to `filename`,
193/// stages it, and commits.
194///
195/// The working tree is reset to a clean HEAD *before* the content is
196/// written, so a prior interrupted publish can't leave dirty/untracked
197/// state behind — and, critically, the reset can't clobber the new
198/// content (which is exactly what happened when the write was done by
199/// the caller beforehand: overwrite then no-op'd back to the parent SHA).
200fn git_commit(
201    repo_path: &Path,
202    filename: &str,
203    content: &str,
204    author_name: &str,
205    author_email: &str,
206    message: &str,
207) -> Result<String, Error> {
208    // Open or initialize the git repository.
209    let repo = match git2::Repository::open(repo_path) {
210        Ok(repo) => {
211            // Reset working tree to clean state.
212            let mut checkout = git2::build::CheckoutBuilder::new();
213            checkout.force();
214            checkout.remove_untracked(true);
215            if let Ok(head) = repo.head() {
216                if let Ok(commit) = head.peel_to_commit() {
217                    repo.reset(
218                        commit.as_object(),
219                        git2::ResetType::Hard,
220                        Some(&mut checkout),
221                    )?;
222                }
223            }
224            repo
225        }
226        Err(_) => git2::Repository::init(repo_path)?,
227    };
228
229    // Write the new content AFTER the reset above, so it survives into
230    // the staged tree.
231    let file_path = repo_path.join(filename);
232    std::fs::write(&file_path, content.as_bytes())
233        .map_err(|e| Error::Write(file_path, e))?;
234
235    // Stage.
236    let mut index = repo.index()?;
237    index.add_path(Path::new(filename))?;
238    index.write()?;
239    let tree_oid = index.write_tree()?;
240
241    // If the tree is identical to the parent's, skip the commit.
242    let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
243    if let Some(ref parent) = parent {
244        if parent.tree_id() == tree_oid {
245            return Ok(parent.id().to_string());
246        }
247    }
248
249    let tree = repo.find_tree(tree_oid)?;
250
251    // Commit.
252    let sig = git2::Signature::now(author_name, author_email)?;
253    let parents: Vec<&git2::Commit> = parent.iter().collect();
254    let commit_oid =
255        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?;
256
257    Ok(commit_oid.to_string())
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    fn read(dir: &Path, name: &str) -> String {
265        std::fs::read_to_string(dir.join(name)).unwrap()
266    }
267
268    /// Regression test for the overwrite no-op bug: committing new content
269    /// over an existing file must update BOTH the file on disk and the
270    /// returned commit SHA. The original bug hard-reset the working tree
271    /// *after* the new file had been written, so overwrite silently
272    /// reverted the content and returned the unchanged parent SHA.
273    #[test]
274    fn overwrite_changes_content_and_sha() {
275        let dir = tempfile::tempdir().unwrap();
276        let p = dir.path();
277        let (name, email) = ("alice", "alice@example.com");
278
279        let sha1 = git_commit(p, "agent.json", "v1", name, email, "first").unwrap();
280        assert_eq!(read(p, "agent.json"), "v1");
281
282        let sha2 = git_commit(p, "agent.json", "v2", name, email, "second").unwrap();
283        assert_eq!(read(p, "agent.json"), "v2", "overwrite must update the file on disk");
284        assert_ne!(sha1, sha2, "overwrite with new content must create a new commit");
285    }
286
287    /// Re-committing identical content is a genuine no-op: same tree as the
288    /// parent, so no new commit is made and the parent SHA is returned.
289    #[test]
290    fn identical_content_is_a_noop() {
291        let dir = tempfile::tempdir().unwrap();
292        let p = dir.path();
293        let (name, email) = ("bob", "bob@example.com");
294
295        let sha1 = git_commit(p, "function.json", "same", name, email, "first").unwrap();
296        let sha2 = git_commit(p, "function.json", "same", name, email, "again").unwrap();
297        assert_eq!(sha1, sha2, "identical content must not create a new commit");
298    }
299}