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    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    // Write file.
168    tokio::fs::write(path.join(filename), content.as_bytes()).await?;
169
170    // Git operations are synchronous — run on a blocking thread.
171    let commit_author_name = client.commit_author_name.clone();
172    let commit_author_email = client.commit_author_email.clone();
173    let message = message.to_string();
174    let filename = filename.to_string();
175
176    tokio::task::spawn_blocking(move || {
177        git_commit(
178            &path,
179            &filename,
180            &commit_author_name,
181            &commit_author_email,
182            &message,
183        )
184    })
185    .await
186    .map_err(|e| Error::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?
187}
188
189/// Opens or initializes the git repo, stages the file, and commits.
190fn git_commit(
191    repo_path: &Path,
192    filename: &str,
193    author_name: &str,
194    author_email: &str,
195    message: &str,
196) -> Result<String, Error> {
197    // Open or initialize the git repository.
198    let repo = match git2::Repository::open(repo_path) {
199        Ok(repo) => {
200            // Reset working tree to clean state.
201            let mut checkout = git2::build::CheckoutBuilder::new();
202            checkout.force();
203            checkout.remove_untracked(true);
204            if let Ok(head) = repo.head() {
205                if let Ok(commit) = head.peel_to_commit() {
206                    repo.reset(
207                        commit.as_object(),
208                        git2::ResetType::Hard,
209                        Some(&mut checkout),
210                    )?;
211                }
212            }
213            repo
214        }
215        Err(_) => git2::Repository::init(repo_path)?,
216    };
217
218    // Stage.
219    let mut index = repo.index()?;
220    index.add_path(Path::new(filename))?;
221    index.write()?;
222    let tree_oid = index.write_tree()?;
223
224    // If the tree is identical to the parent's, skip the commit.
225    let parent = repo.head().ok().and_then(|h| h.peel_to_commit().ok());
226    if let Some(ref parent) = parent {
227        if parent.tree_id() == tree_oid {
228            return Ok(parent.id().to_string());
229        }
230    }
231
232    let tree = repo.find_tree(tree_oid)?;
233
234    // Commit.
235    let sig = git2::Signature::now(author_name, author_email)?;
236    let parents: Vec<&git2::Commit> = parent.iter().collect();
237    let commit_oid =
238        repo.commit(Some("HEAD"), &sig, &sig, message, &tree, &parents)?;
239
240    Ok(commit_oid.to_string())
241}