diff --git a/dsl/api/api.go b/dsl/api/api.go new file mode 100644 index 00000000..32fbcc16 --- /dev/null +++ b/dsl/api/api.go @@ -0,0 +1,47 @@ +package api + +import ( + "context" + + "github.com/yaoapp/yao/dsl/types" +) + +// YaoAPI is the MCP client DSL manager +type YaoAPI struct { + root string // The relative path of the MCP client DSL +} + +// New returns a new connector DSL manager +func New(root string) types.Manager { + return &YaoAPI{root: root} +} + +// Loaded return all loaded DSLs +func (api *YaoAPI) Loaded(ctx context.Context) (map[string]*types.Info, error) { + return nil, nil +} + +// Load will unload the DSL first, then load the DSL from DB or file system +func (api *YaoAPI) Load(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Reload will unload the DSL first, then reload the DSL from DB or file system +func (api *YaoAPI) Reload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Unload will unload the DSL from memory +func (api *YaoAPI) Unload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Validate will validate the DSL from source +func (api *YaoAPI) Validate(ctx context.Context, source string) (bool, []types.LintMessage) { + return false, nil +} + +// Execute will execute the DSL +func (api *YaoAPI) Execute(ctx context.Context, method string, args ...any) (any, error) { + return nil, nil +} diff --git a/dsl/connector/connector.go b/dsl/connector/connector.go new file mode 100644 index 00000000..94f44217 --- /dev/null +++ b/dsl/connector/connector.go @@ -0,0 +1,47 @@ +package connector + +import ( + "context" + + "github.com/yaoapp/yao/dsl/types" +) + +// YaoConnector is the connector DSL manager +type YaoConnector struct { + root string // The relative path of the connector DSL +} + +// New returns a new connector DSL manager +func New(root string) types.Manager { + return &YaoConnector{root: root} +} + +// Loaded return all loaded DSLs +func (c *YaoConnector) Loaded(ctx context.Context) (map[string]*types.Info, error) { + return nil, nil +} + +// Load will unload the DSL first, then load the DSL from DB or file system +func (c *YaoConnector) Load(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Unload will unload the DSL from memory +func (c *YaoConnector) Unload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Reload will unload the DSL first, then reload the DSL from DB or file system +func (c *YaoConnector) Reload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Validate will validate the DSL from source +func (c *YaoConnector) Validate(ctx context.Context, source string) (bool, []types.LintMessage) { + return false, nil +} + +// Execute will execute the DSL +func (c *YaoConnector) Execute(ctx context.Context, method string, args ...any) (any, error) { + return nil, nil +} diff --git a/dsl/db.go b/dsl/db.go new file mode 100644 index 00000000..45cdc6c3 --- /dev/null +++ b/dsl/db.go @@ -0,0 +1,277 @@ +package dsl + +import ( + "fmt" + "strings" + "time" + + jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou/model" + "github.com/yaoapp/yao/dsl/types" +) + +// getInfoFromDB get the info from the db +func (dsl *DSL) dbInspect(id string) (*types.Info, bool, error) { + + // Get from database + m := model.Select("__yao.dsl") + + // Get the info + var info types.Info + rows, err := m.Get(model.QueryParam{ + Wheres: []model.QueryWhere{{Column: "dsl_id", Value: id}}, + Select: []interface{}{ + "dsl_id", + "type", + "label", + "path", + "sort", + "tags", + "description", + "status", + "store", + "mtime", + "ctime", + }, + Limit: 1, + Orders: []model.QueryOrder{{Column: "sort", Option: "asc"}, {Column: "mtime", Option: "desc"}}, + }) + if err != nil { + return nil, false, err + } + + if len(rows) == 0 { + return nil, false, nil + } + + raw, err := jsoniter.Marshal(rows[0]) + if err != nil { + return nil, false, err + } + + err = jsoniter.Unmarshal(raw, &info) + if err != nil { + return nil, false, err + } + + return &info, true, nil +} + +// getSourceFromDB get the source from the db +func (dsl *DSL) dbSource(id string) (string, bool, error) { + + // Get from database + m := model.Select("__yao.dsl") + + // Get the source + rows, err := m.Get(model.QueryParam{ + Wheres: []model.QueryWhere{{Column: "dsl_id", Value: id}}, + Select: []interface{}{"source"}, + Limit: 1, + }) + if err != nil { + return "", false, err + } + + if len(rows) == 0 { + return "", false, nil + } + + source, ok := rows[0]["source"].(string) + if !ok { + return "", true, fmt.Errorf("%s %s source is not a string", dsl.Type, id) + } + + return source, true, nil +} + +// getListFromDB get the list from the db +func (dsl *DSL) dbList(options *types.ListOptions) ([]*types.Info, error) { + + // Get from database + m := model.Select("__yao.dsl") + + var orders []model.QueryOrder = []model.QueryOrder{{Column: "mtime", Option: "desc"}} + if options.Sort == "sort" { + orders = []model.QueryOrder{{Column: "sort", Option: "asc"}} + } + + var wheres []model.QueryWhere = []model.QueryWhere{{Column: "type", Value: dsl.Type}} + + // Filter by tags + if len(options.Tags) > 0 { + var orwheres []model.QueryWhere = []model.QueryWhere{} + for _, tag := range options.Tags { + match := "%" + strings.TrimSpace(tag) + "%" + orwheres = append(orwheres, model.QueryWhere{Column: "tags", Value: match, OP: "like", Method: "orwhere"}) + } + wheres = append(wheres, model.QueryWhere{Wheres: orwheres}) + } + + // Get the list + rows, err := m.Get(model.QueryParam{ + Wheres: []model.QueryWhere{{Column: "type", Value: dsl.Type}}, + Select: []interface{}{"dsl_id", "label", "path", "sort", "tags", "description", "status", "store", "mtime", "ctime"}, + Orders: orders, + }) + if err != nil { + return nil, err + } + + if len(rows) == 0 { + return nil, nil + } + + var infos []*types.Info + raw, err := jsoniter.Marshal(rows) + if err != nil { + return nil, err + } + + err = jsoniter.Unmarshal(raw, &infos) + if err != nil { + return nil, err + } + + return infos, nil +} + +func (dsl *DSL) dbCreate(options *types.CreateOptions) error { + + if options.Source == "" { + return fmt.Errorf("%s %s source is required", dsl.Type, options.ID) + } + + // Get info from source + var info types.Info + err := jsoniter.Unmarshal([]byte(options.Source), &info) + if err != nil { + return err + } + + // Get the info + m := model.Select("__yao.dsl") + data := map[string]interface{}{ + "source": options.Source, + "dsl_id": options.ID, + "type": dsl.Type, + "label": info.Label, + "path": info.Path, + "sort": info.Sort, + "tags": info.Tags, + "description": info.Description, + "status": info.Status, + "store": info.Store, + "mtime": time.Now().Unix(), + "ctime": time.Now().Unix(), + } + + _, err = m.Create(data) + if err != nil { + return err + } + + return nil +} + +func (dsl *DSL) dbUpdate(options *types.UpdateOptions) error { + if options.Source == "" && options.Info == nil { + return fmt.Errorf("%s %s one of source or info is required", dsl.Type, options.ID) + } + + m := model.Select("__yao.dsl") + + // Check if the dsl exists + rows, err := m.Get(model.QueryParam{ + Wheres: []model.QueryWhere{{Column: "dsl_id", Value: options.ID}}, + Select: []interface{}{"id", "dsl_id"}, + Limit: 1, + }) + if err != nil { + return err + } + + if len(rows) == 0 { + return fmt.Errorf("%s %s not found", dsl.Type, options.ID) + } + + row := rows[0] + + // update source + var data map[string]interface{} = map[string]interface{}{} + if options.Source != "" { + data["source"] = options.Source + } else { + // Update info + if options.Info.Label != "" { + data["label"] = options.Info.Label + } + + if options.Info.Path != "" { + data["path"] = options.Info.Path + } + + if options.Info.Sort != 0 { + data["sort"] = options.Info.Sort + } + + if options.Info.Tags != nil { + data["tags"] = options.Info.Tags + } + + if options.Info.Description != "" { + data["description"] = options.Info.Description + } + + } + + // Update the data + err = m.Update(row["id"], data) + if err != nil { + return err + } + + return nil +} + +func (dsl *DSL) dbDelete(id string) error { + + // Get from database + m := model.Select("__yao.dsl") + + // Check if the dsl exists + rows, err := m.Get(model.QueryParam{ + Wheres: []model.QueryWhere{{Column: "dsl_id", Value: id}}, + Select: []interface{}{"id", "dsl_id"}, + Limit: 1, + }) + if err != nil { + return err + } + + if len(rows) == 0 { + return fmt.Errorf("%s %s not found", dsl.Type, id) + } + + // Delete the dsl + row := rows[0] + return m.Delete(row["id"]) +} + +func (dsl *DSL) dbExists(id string) (bool, error) { + + // Get from database + m := model.Select("__yao.dsl") + + // Check if the dsl exists + rows, err := m.Get(model.QueryParam{ + Wheres: []model.QueryWhere{{Column: "dsl_id", Value: id}}, + Select: []interface{}{"id", "dsl_id"}, + Limit: 1, + }) + if err != nil { + return false, err + } + + return len(rows) > 0, nil +} diff --git a/dsl/dsl.go b/dsl/dsl.go new file mode 100644 index 00000000..edc11f12 --- /dev/null +++ b/dsl/dsl.go @@ -0,0 +1,319 @@ +package dsl + +import ( + "context" + "fmt" + + "github.com/yaoapp/yao/dsl/api" + "github.com/yaoapp/yao/dsl/connector" + "github.com/yaoapp/yao/dsl/mcp" + "github.com/yaoapp/yao/dsl/model" + "github.com/yaoapp/yao/dsl/types" +) + +// DSL is the base DSL struct +type DSL struct { + Type types.Type + exts []string + root string + manager types.Manager +} + +// New returns a new DSL manager +func New(typ types.Type) (types.DSL, error) { + var manager types.Manager + + // Get the root path and the extensions of the type + root, exts := types.TypeRootAndExts(typ) + + // Create the manager + switch typ { + case types.TypeConnector: + exts = []string{".conn.yao", ".conn.jsonc", ".conn.json"} + manager = connector.New(root) + + case types.TypeModel: + exts = []string{".mod.yao", ".mod.jsonc", ".mod.json"} + manager = model.New(root) + + case types.TypeMCPClient: + exts = []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"} + manager = mcp.NewClient(root) + + // case types.TypeMCPServer: + // exts = []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"} + // manager = mcp.NewServer(root) + + case types.TypeAPI: + exts = []string{".http.yao", ".http.jsonc", ".http.json"} + manager = api.New(root) + + default: + return nil, fmt.Errorf("dsl manager is not initialized, %s not supported", typ) + } + + return &DSL{Type: typ, manager: manager, root: root, exts: exts}, nil +} + +// Inspect DSL +func (dsl *DSL) Inspect(ctx context.Context, id string) (*types.Info, error) { + + // Get the info from the db + info, exists, err := dsl.dbInspect(id) + if err != nil { + return nil, err + } + + if !exists { + // Get the info from the file + info, exists, err = dsl.fsInspect(types.ToPath(dsl.Type, id)) + if err != nil { + return nil, err + } + + if !exists { + return nil, fmt.Errorf("dsl not found, %s", id) + } + } + + // Merge the status from the manager + loaded, err := dsl.manager.Loaded(ctx) + if err != nil { + return info, err + } + + // Check if the DSL is loaded + if _, ok := loaded[id]; ok { + info.Status = types.StatusLoaded + } + + return info, nil +} + +// Path Get Path by id, ( If the DSL is saved as file, return the file path ) +func (dsl *DSL) Path(ctx context.Context, id string) (string, error) { + return types.ToPath(dsl.Type, id), nil +} + +// Source Get Source by id +func (dsl *DSL) Source(ctx context.Context, id string) (string, error) { + + // Get the source from the db + source, exists, err := dsl.dbSource(id) + if err != nil { + return "", err + } + + if !exists { + // Get the source from the file + source, exists, err = dsl.fsSource(types.ToPath(dsl.Type, id)) + if err != nil { + return "", err + } + + if !exists { + return "", fmt.Errorf("%s DSL not found, %s", dsl.Type, id) + } + } + + return source, nil +} + +// List DSLs +func (dsl *DSL) List(ctx context.Context, opts *types.ListOptions) ([]*types.Info, error) { + // Get the list from the db + dbList, err := dsl.dbList(opts) + if err != nil { + return nil, err + } + + // Get the list from the file + fileList, err := dsl.fsList(opts) + if err != nil { + return nil, err + } + + // Merge the list and unique + list := []*types.Info{} + unique := make(map[string]bool) + for _, info := range dbList { + if _, ok := unique[info.ID]; !ok { + list = append(list, info) + unique[info.ID] = true + } + } + for _, info := range fileList { + if _, ok := unique[info.ID]; !ok { + list = append(list, info) + unique[info.ID] = true + } + } + + // Merge the status from the manager + loaded, err := dsl.manager.Loaded(ctx) + if err != nil { + return nil, err + } + + // Merge the status from the manager + for _, info := range list { + if _, ok := loaded[info.ID]; ok { + info.Status = types.StatusLoaded + } + } + + return list, nil +} + +// Create DSL +func (dsl *DSL) Create(ctx context.Context, options *types.CreateOptions) error { + if options.Store == types.StoreTypeDB { + err := dsl.dbCreate(options) + if err != nil { + return err + } + } + + if options.Store == types.StoreTypeFile { + err := dsl.fsCreate(options) + if err != nil { + return err + } + } + + // Load the DSL + err := dsl.Load(ctx, options.ID, options.LoadOptions) + if err != nil { + return err + } + + return nil +} + +// Exists Check if the DSL exists +func (dsl *DSL) Exists(ctx context.Context, id string) (bool, error) { + // Check if the DSL exists in the db + exists, err := dsl.dbExists(id) + if err != nil { + return false, err + } + + if exists { + return true, nil + } + + // Check if the DSL exists in the file + return dsl.fsExists(id) +} + +// Update DSL +func (dsl *DSL) Update(ctx context.Context, options *types.UpdateOptions) error { + + // Exists + info, exists, err := dsl.dbInspect(options.ID) + if err != nil { + return err + } + + if !exists { + info, exists, err = dsl.fsInspect(types.ToPath(dsl.Type, options.ID)) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%s DSL not found, %s", dsl.Type, options.ID) + } + } + + // Update the DSL in the db + if info.Store == types.StoreTypeDB { + err := dsl.dbUpdate(options) + if err != nil { + return err + } + + // Reload the DSL + return dsl.manager.Reload(ctx, options.ID, options.ReloadOptions) + } + + // Update the DSL in the file + err = dsl.fsUpdate(options) + if err != nil { + return err + } + + // Reload the DSL + return dsl.manager.Reload(ctx, options.ID, options.ReloadOptions) +} + +// Delete DSL +func (dsl *DSL) Delete(ctx context.Context, id string, options ...interface{}) error { + // Exists + info, exists, err := dsl.dbInspect(id) + if err != nil { + return err + } + + if !exists { + info, exists, err = dsl.fsInspect(types.ToPath(dsl.Type, id)) + if err != nil { + return err + } + if !exists { + return fmt.Errorf("%s DSL not found, %s", dsl.Type, id) + } + } + + var unloadOptions interface{} + if len(options) > 0 { + unloadOptions = options[0] + } + + if info.Store == types.StoreTypeDB { + err = dsl.dbDelete(id) + if err != nil { + return err + } + + // Unload the DSL + return dsl.manager.Unload(ctx, id, unloadOptions) + } + + err = dsl.fsDelete(id) + if err != nil { + return err + } + + // Unload the DSL + return dsl.manager.Unload(ctx, id, unloadOptions) + +} + +// Load DSL +func (dsl *DSL) Load(ctx context.Context, id string, options interface{}) error { + return dsl.manager.Load(ctx, id, options) +} + +// Unload DSL +func (dsl *DSL) Unload(ctx context.Context, id string, options ...interface{}) error { + var unloadOptions interface{} + if len(options) > 0 { + unloadOptions = options[0] + } + return dsl.manager.Unload(ctx, id, unloadOptions) +} + +// Reload DSL +func (dsl *DSL) Reload(ctx context.Context, id string, options interface{}) error { + return dsl.manager.Reload(ctx, id, options) +} + +// Execute DSL (Some DSLs can be executed) +func (dsl *DSL) Execute(ctx context.Context, method string, args ...any) (any, error) { + return dsl.manager.Execute(ctx, method, args...) +} + +// Validate DSL +func (dsl *DSL) Validate(ctx context.Context, source string) (bool, []types.LintMessage) { + return dsl.manager.Validate(ctx, source) +} diff --git a/dsl/fs.go b/dsl/fs.go new file mode 100644 index 00000000..742db5b1 --- /dev/null +++ b/dsl/fs.go @@ -0,0 +1,203 @@ +package dsl + +import ( + "fmt" + + jsoniter "github.com/json-iterator/go" + "github.com/yaoapp/gou/application" + "github.com/yaoapp/yao/dsl/types" +) + +// getInfoFromFile get the info from the file +func (dsl *DSL) fsInspect(id string, path ...string) (*types.Info, bool, error) { + file := "" + if len(path) > 0 { + file = path[0] + } else { + file = types.ToPath(dsl.Type, id) + } + + var info types.Info = types.Info{ID: id, Path: file} + exists, err := application.App.Exists(file) + if err != nil { + return nil, false, err + } + if !exists { + return nil, false, nil + } + + // Read the file + data, err := application.App.Read(file) + if err != nil { + return nil, false, err + } + + // Unmarshal the data to the info + err = application.Parse(file, data, &info) + if err != nil { + return nil, true, err + } + + // Merge the mtime and ctime + fileInfo, err := application.App.Info(file) + if err != nil { + return nil, true, err + } + + info.Mtime = fileInfo.ModTime() + info.Ctime = fileInfo.ModTime() + return &info, true, nil +} + +// getSourceFromFile get the source from the file +func (dsl *DSL) fsSource(id string) (string, bool, error) { + path := types.ToPath(dsl.Type, id) + exists, err := application.App.Exists(path) + if err != nil { + return "", false, err + } + if !exists { + return "", false, nil + } + + // Read the file + data, err := application.App.Read(path) + if err != nil { + return "", false, err + } + return string(data), true, nil +} + +// getListFromPath get the list from the path +func (dsl *DSL) fsList(options *types.ListOptions) ([]*types.Info, error) { + root, exts := types.TypeRootAndExts(dsl.Type) + var infos []*types.Info = []*types.Info{} + patterns := []string{} + for _, ext := range exts { + patterns = append(patterns, "*"+ext) + } + var errs []error + err := application.App.Walk(root, func(root, file string, isdir bool) error { + if isdir { + return nil + } + id := types.WithTypeToID(dsl.Type, file) + info, _, err := dsl.fsInspect(id, file) + if err != nil { + errs = append(errs, err) + return nil + } + + // Filter by options + if len(options.Tags) > 0 { + if len(info.Tags) == 0 { + return nil + } + + for _, tag := range options.Tags { + for _, t := range info.Tags { + if t == tag { + infos = append(infos, info) + return nil + } + } + } + } + + // Add to the list + infos = append(infos, info) + return err + }, patterns...) + + return infos, err +} + +func (dsl *DSL) fsCreate(options *types.CreateOptions) error { + + path := types.ToPath(dsl.Type, options.ID) + + // Check if the file is a directory + exists, err := application.App.Exists(path) + if err != nil { + return err + } + + if exists { + return fmt.Errorf("%v %s already exists", dsl.Type, options.ID) + } + + // Create the file + return application.App.Write(path, []byte(options.Source)) +} + +func (dsl *DSL) fsUpdate(options *types.UpdateOptions) error { + + // Validate the options + if options.Source == "" && options.Info == nil { + return fmt.Errorf("%v %s one of source or info is required", dsl.Type, options.ID) + } + + path := types.ToPath(dsl.Type, options.ID) + + // Check if the file exists + exists, err := application.App.Exists(path) + if err != nil { + return err + } + + if !exists { + return fmt.Errorf("%v %s not found", dsl.Type, options.ID) + } + + // Update source + if options.Source != "" { + return application.App.Write(path, []byte(options.Source)) + } + + // Update info + var source map[string]interface{} + data, err := application.App.Read(path) + if err != nil { + return err + } + + err = application.Parse(path, data, &source) + if err != nil { + return err + } + + // Update the info + source["id"] = options.ID + source["label"] = options.Info.Label + source["tags"] = options.Info.Tags + source["description"] = options.Info.Description + new, err := jsoniter.MarshalIndent(source, "", " ") + if err != nil { + return err + } + + return application.App.Write(path, []byte(new)) +} + +func (dsl *DSL) fsDelete(id string) error { + + path := types.ToPath(dsl.Type, id) + + // Check if the file is a directory + exists, err := application.App.Exists(path) + if err != nil { + return err + } + + if !exists { + return fmt.Errorf("%v %s not found", dsl.Type, id) + } + + // Delete the file + return application.App.Remove(path) +} + +func (dsl *DSL) fsExists(id string) (bool, error) { + path := types.ToPath(dsl.Type, id) + return application.App.Exists(path) +} diff --git a/dsl/mcp/client.go b/dsl/mcp/client.go new file mode 100644 index 00000000..5f55bdb7 --- /dev/null +++ b/dsl/mcp/client.go @@ -0,0 +1,52 @@ +package mcp + +import ( + "context" + + "github.com/yaoapp/yao/dsl/types" +) + +// YaoMCPClient is the MCP client DSL manager +type YaoMCPClient struct { + root string // The relative path of the MCP client DSL +} + +// NewClient returns a new MCP client DSL manager +func NewClient(root string) types.Manager { + return New(root) +} + +// New returns a new connector DSL manager +func New(root string) types.Manager { + return &YaoMCPClient{root: root} +} + +// Loaded return all loaded DSLs +func (client *YaoMCPClient) Loaded(ctx context.Context) (map[string]*types.Info, error) { + return nil, nil +} + +// Load will unload the DSL first, then load the DSL from DB or file system +func (client *YaoMCPClient) Load(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Unload will unload the DSL from memory +func (client *YaoMCPClient) Unload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Reload will unload the DSL first, then reload the DSL from DB or file system +func (client *YaoMCPClient) Reload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Validate will validate the DSL from source +func (client *YaoMCPClient) Validate(ctx context.Context, source string) (bool, []types.LintMessage) { + return false, nil +} + +// Execute will execute the DSL +func (client *YaoMCPClient) Execute(ctx context.Context, method string, args ...any) (any, error) { + return nil, nil +} diff --git a/dsl/mcp/server.go b/dsl/mcp/server.go new file mode 100644 index 00000000..e6819775 --- /dev/null +++ b/dsl/mcp/server.go @@ -0,0 +1,47 @@ +package mcp + +import ( + "context" + + "github.com/yaoapp/yao/dsl/types" +) + +// YaoMCPServer is the MCP client DSL manager +type YaoMCPServer struct { + root string // The relative path of the MCP client DSL +} + +// NewServer returns a new MCP server DSL manager +func NewServer(root string) types.Manager { + return &YaoMCPServer{root: root} +} + +// Loaded return all loaded DSLs +func (server *YaoMCPServer) Loaded(ctx context.Context) (map[string]*types.Info, error) { + return nil, nil +} + +// Load will unload the DSL first, then load the DSL from DB or file system +func (server *YaoMCPServer) Load(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Unload will unload the DSL from memory +func (server *YaoMCPServer) Unload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Reload will unload the DSL first, then reload the DSL from DB or file system +func (server *YaoMCPServer) Reload(ctx context.Context, id string, options interface{}) error { + return nil +} + +// Validate will validate the DSL from source +func (server *YaoMCPServer) Validate(ctx context.Context, source string) (bool, []types.LintMessage) { + return false, nil +} + +// Execute will execute the DSL +func (server *YaoMCPServer) Execute(ctx context.Context, method string, args ...any) (any, error) { + return nil, nil +} diff --git a/dsl/model/model.go b/dsl/model/model.go new file mode 100644 index 00000000..fbd4f9bb --- /dev/null +++ b/dsl/model/model.go @@ -0,0 +1,139 @@ +package model + +import ( + "context" + "fmt" + + "github.com/yaoapp/gou/model" + "github.com/yaoapp/yao/dsl/types" +) + +// YaoModel is the MCP client DSL manager +type YaoModel struct { + root string // The relative path of the MCP client DSL +} + +// NewClient returns a new MCP client DSL manager +func NewClient(root string) types.Manager { + return New(root) +} + +// New returns a new connector DSL manager +func New(root string) types.Manager { + return &YaoModel{root: root} +} + +// Loaded return all loaded DSLs +func (m *YaoModel) Loaded(ctx context.Context) (map[string]*types.Info, error) { + + infos := map[string]*types.Info{} + for id, mod := range model.Models { + infos[id] = &types.Info{ + ID: id, + Type: types.TypeModel, + Label: mod.MetaData.Name, + Path: mod.File, + Sort: 999, + Tags: []string{}, + Description: "Description", + } + } + + return infos, nil +} + +// Load will unload the DSL first, then load the DSL from DB or file system +func (m *YaoModel) Load(ctx context.Context, id string, options interface{}) error { + var opts map[string]interface{} + if v, ok := options.(map[string]interface{}); ok { + opts = v + } + + var migration bool = false + if v, ok := opts["migration"]; ok { + migration = v.(bool) + } + + var reset bool = false + if v, ok := opts["reset"]; ok { + reset = v.(bool) + } + + path := types.ToPath(types.TypeModel, id) + mod, err := model.LoadSync(path, id) + if err != nil { + return err + } + + if migration || reset { + return mod.Migrate(reset, model.WithDonotInsertValues(true)) + } + + return nil +} + +// Unload will unload the DSL from memory +func (m *YaoModel) Unload(ctx context.Context, id string, options interface{}) error { + + var opts map[string]interface{} + if v, ok := options.(map[string]interface{}); ok { + opts = v + } + + var dropTable bool = false + if v, ok := opts["dropTable"]; ok { + dropTable = v.(bool) + } + + mod := model.Select(id) + if mod == nil { + return fmt.Errorf("model %s not found", id) + } + + if dropTable { + return mod.DropTable() + } + + return nil +} + +// Reload will unload the DSL first, then reload the DSL from DB or file system +func (m *YaoModel) Reload(ctx context.Context, id string, options interface{}) error { + + var opts map[string]interface{} + if v, ok := options.(map[string]interface{}); ok { + opts = v + } + + var migration bool = false + if v, ok := opts["migration"]; ok { + migration = v.(bool) + } + + var reset bool = false + if v, ok := opts["reset"]; ok { + reset = v.(bool) + } + + // Reload the model + path := types.ToPath(types.TypeModel, id) + mod, err := model.LoadSync(path, id) + if err != nil { + return err + } + + if migration || reset { + return mod.Migrate(reset, model.WithDonotInsertValues(true)) + } + return nil +} + +// Validate will validate the DSL from source +func (m *YaoModel) Validate(ctx context.Context, source string) (bool, []types.LintMessage) { + return true, []types.LintMessage{} +} + +// Execute will execute the DSL +func (m *YaoModel) Execute(ctx context.Context, method string, args ...any) (any, error) { + return nil, fmt.Errorf("Not implemented") +} diff --git a/dsl/types/interfaces.go b/dsl/types/interfaces.go new file mode 100644 index 00000000..87258205 --- /dev/null +++ b/dsl/types/interfaces.go @@ -0,0 +1,49 @@ +package types + +import "context" + +// DSL interface +type DSL interface { + Inspect(ctx context.Context, id string) (*Info, error) // Inspect DSL + Path(ctx context.Context, id string) (string, error) // Get Path by id, ( If the DSL is saved as file, return the file path ) + Source(ctx context.Context, id string) (string, error) // Get Source by id + List(ctx context.Context, opts *ListOptions) ([]*Info, error) // List All DSLs including unloaded/error DSLs + Exists(ctx context.Context, id string) (bool, error) // Check if the DSL exists + + // DSL Operations + Create(ctx context.Context, options *CreateOptions) error // Create DSL, Create will unload the DSL first, then create the DSL to DB + Update(ctx context.Context, options *UpdateOptions) error // Update DSL, Update will unload the DSL first, then update the DSL, if update info only, will not unload the DSL + Delete(ctx context.Context, id string, unloadOptions ...interface{}) error // Delete DSL, Delete will unload the DSL first, then delete the DSL file + + // Load manager + Load(ctx context.Context, id string, options interface{}) error // Load DSL, Load will unload the DSL first, then load the DSL from DB or file system + Reload(ctx context.Context, id string, options interface{}) error // Reload DSL, Reload will unload the DSL first, then reload the DSL from DB or file system + Unload(ctx context.Context, id string, options ...interface{}) error // Unload DSL, Unload will unload the DSL from memory + + // Execute + Execute(ctx context.Context, method string, args ...any) (any, error) // Execute DSL (Some DSLs can be executed) + + // Validate + Validate(ctx context.Context, source string) (bool, []LintMessage) // Validate DSL, Validate will validate the DSL from source +} + +// Manager interface +type Manager interface { + // Get all loaded DSLs + Loaded(ctx context.Context) (map[string]*Info, error) // Get all loaded DSLs + + // Load DSL, Load will unload the DSL first, then load the DSL from DB or file system + Load(ctx context.Context, id string, options interface{}) error + + // Unload DSL, Unload will unload the DSL from memory + Unload(ctx context.Context, id string, options interface{}) error + + // Reload DSL, Reload will unload the DSL first, then reload the DSL from DB or file system + Reload(ctx context.Context, id string, options interface{}) error + + // Validate DSL, Validate will validate the DSL from source + Validate(ctx context.Context, source string) (bool, []LintMessage) + + // Execute DSL (Some DSLs can be executed) + Execute(ctx context.Context, method string, args ...any) (any, error) +} diff --git a/dsl/types/types.go b/dsl/types/types.go new file mode 100644 index 00000000..1b1da3eb --- /dev/null +++ b/dsl/types/types.go @@ -0,0 +1,126 @@ +package types + +import ( + "time" +) + +// Type for DSL +type Type string + +// Status for DSL +type Status string + +// StoreType for DSL store +type StoreType string + +// LintSeverity for DSL linter +type LintSeverity string + +// StoreType for DSL store +const ( + StoreTypeDB StoreType = "db" + StoreTypeFile StoreType = "file" +) + +// Status for DSL +const ( + StatusLoading Status = "loading" + StatusLoaded Status = "loaded" + StatusError Status = "error" +) + +// LintSeverity for DSL linter +const ( + LintSeverityError LintSeverity = "error" + LintSeverityWarning LintSeverity = "warning" + LintSeverityInfo LintSeverity = "info" + LintSeverityHint LintSeverity = "hint" +) + +// Type for DSL +const ( + // TypeModel for model + TypeModel Type = "model" + // TypeAPI for api + TypeAPI Type = "api" + // TypeConnector for connector + TypeConnector Type = "connector" + // TypeMCPServer for MCP server + TypeMCPServer Type = "mcp-server" + // TypeMCPClient for MCP client + TypeMCPClient Type = "mcp-client" + // TypeStore for store + TypeStore Type = "store" + // TypeSchedule for schedule + TypeSchedule Type = "schedule" + + // TypeTable for table + TypeTable Type = "table" + // TypeForm for form + TypeForm Type = "form" + // TypeList for list + TypeList Type = "list" + // TypeChart for chart + TypeChart Type = "chart" + // TypeDashboard for dashboard + TypeDashboard Type = "dashboard" + + // TypeFlow for flow + TypeFlow Type = "flow" + // TypePipe for pipe + TypePipe Type = "pipe" + // TypeAIGC for aigc + TypeAIGC Type = "aigc" + + // TypeUnknown for unknown + TypeUnknown Type = "unknown" +) + +// Info for DSL +type Info struct { + ID string + Type Type + Sort int + Path string + Label string + Description string + Tags []string + Status Status + Store StoreType + Mtime time.Time + Ctime time.Time +} + +// ListOptions for DSL list +type ListOptions struct { + Sort string + Order string + Tags []string +} + +// CreateOptions for DSL upsert +type CreateOptions struct { + ID string // ID is the id of the DSL, if not provided, a new id will be generated, required + Source string // Source is the source of the DSL, if not provided, the DSL will be loaded from the file system + Store StoreType // Store is the store type of the DSL, if not provided, the DSL will be loaded from the file system + LoadOptions interface{} // LoadOptions is the options for the DSL, if not provided, the DSL will be loaded from the file system +} + +// UpdateOptions for DSL upsert +type UpdateOptions struct { + ID string // ID is the id of the DSL, if not provided, a new id will be generated, required + Info *Info // Info is the info of the DSL, if not provided, the DSL will be loaded from the file system, one of info or source must be provided + Source string // Source is the source of the DSL, if not provided, the DSL will be loaded from the file system, one of info or source must be provided + ReloadOptions interface{} // ReloadOptions is the options for the DSL, if not provided, the DSL will be loaded from the file system +} + +// LintMessage for DSL linter +type LintMessage struct { + File string + Line int + Column int + Message string + Severity LintSeverity +} + +var lintMessages []LintMessage diff --git a/dsl/types/utils.go b/dsl/types/utils.go new file mode 100644 index 00000000..545ab0b4 --- /dev/null +++ b/dsl/types/utils.go @@ -0,0 +1,194 @@ +package types + +import ( + "os" + "path/filepath" + "strings" +) + +// ToPath convert id to path +func ToPath(typ Type, id string) string { + + // Get the root path and the extensions of the type + root, exts := TypeRootAndExts(typ) + ext := ".yao" + if len(exts) > 0 { + ext = exts[0] + } + + // 1. Replace all . to / + path := strings.ReplaceAll(id, ".", string(os.PathSeparator)) + // 2. Replace all __ to . + path = strings.ReplaceAll(path, "__", ".") + // 3. Join the root path + return filepath.Join(root, path) + ext +} + +// ToID convert file path to id +func ToID(path string) string { + typ := DetectType(path) + return WithTypeToID(typ, path) +} + +// WithTypeToID convert file path to id +func WithTypeToID(typ Type, path string) string { + + // Get the root path and the extensions of the type + root, exts := TypeRootAndExts(typ) + + // 0. if the first character is /, remove it + if strings.HasPrefix(path, string(os.PathSeparator)) { + path = strings.TrimPrefix(path, string(os.PathSeparator)) + } + + // 1. Split the path by / + parts := strings.Split(path, string(os.PathSeparator)) + if len(parts) > 0 && parts[0] == root { + // Skip the root path + parts = parts[1:] + + // Remove the extension only if parts is not empty + if len(parts) > 0 { + last := parts[len(parts)-1] + for _, ext := range exts { + if strings.HasSuffix(last, ext) { + parts[len(parts)-1] = strings.TrimSuffix(last, ext) + } + } + } + + // Join the parts + path = strings.Join(parts, string(os.PathSeparator)) + } + + // 2. Replace All . to __ + path = strings.ReplaceAll(path, ".", "__") + + // 3. Replace all / to . + path = strings.ReplaceAll(path, string(os.PathSeparator), ".") + + return path +} + +// DetectType detect the type by the file path +func DetectType(path string) Type { + parts := strings.Split(path, string(os.PathSeparator)) + if len(parts) < 2 { + return TypeUnknown + } + + root := parts[0] + last := parts[len(parts)-1] + extParts := strings.Split(last, ".") + if len(extParts) < 2 { + return TypeUnknown + } + ext := extParts[len(extParts)-2] + + // Detect the type by the extension + switch ext { + case "http": + return TypeAPI + case "sch": + return TypeSchedule + case "table": + return TypeTable + case "form": + return TypeForm + case "list": + return TypeList + case "chart": + return TypeChart + case "dash": + return TypeDashboard + case "flow": + return TypeFlow + case "pipe": + return TypePipe + case "ai": + return TypeAIGC + case "mod": + return TypeModel + case "conn": + return TypeConnector + case "lru", "redis", "mongo", "badger": + return TypeStore + } + + // Detect the type by the root path + switch root { + case "models": + return TypeModel + case "connectors": + return TypeConnector + case "mcps": + return TypeMCPClient + case "apis": + if ext == "http" { + return TypeAPI + } + if ext == "mcp" { + return TypeMCPServer + } + return TypeUnknown + case "schedules": + return TypeSchedule + case "tables": + return TypeTable + case "forms": + return TypeForm + case "lists": + return TypeList + case "charts": + return TypeChart + case "dashboards": + return TypeDashboard + case "flows": + return TypeFlow + case "pipes": + return TypePipe + case "aigcs": + return TypeAIGC + case "stores": + return TypeStore + default: + return TypeUnknown + } + +} + +// TypeRootAndExts return the root path and the extensions of the type +func TypeRootAndExts(typ Type) (string, []string) { + switch typ { + case TypeModel: + return "models", []string{".mod.yao", ".mod.jsonc", ".mod.json"} + case TypeConnector: + return "connectors", []string{".conn.yao", ".conn.jsonc", ".conn.json"} + case TypeMCPClient, TypeMCPServer: + return "mcps", []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"} + case TypeAPI: + return "apis", []string{".http.yao", ".http.jsonc", ".http.json"} + case TypeSchedule: + return "schedules", []string{".sch.yao", ".sch.jsonc", ".sch.json"} + case TypeTable: + return "tables", []string{".table.yao", ".table.jsonc", ".table.json"} + case TypeForm: + return "forms", []string{".form.yao", ".form.jsonc", ".form.json"} + case TypeList: + return "lists", []string{".list.yao", ".list.jsonc", ".list.json"} + case TypeChart: + return "charts", []string{".chart.yao", ".chart.jsonc", ".chart.json"} + case TypeDashboard: + return "dashboards", []string{".dash.yao", ".dash.jsonc", ".dash.json"} + case TypeFlow: + return "flows", []string{".flow.yao", ".flow.jsonc", ".flow.json"} + case TypePipe: + return "pipes", []string{".pipe.yao", ".pipe.jsonc", ".pipe.json"} + case TypeAIGC: + return "aigcs", []string{".ai.yao", ".ai.jsonc", ".ai.json"} + case TypeStore: + return "stores", []string{".lru.yao", ".redis.yao", ".mongo.yao", ".badger.yao", ".store.yao", ".store.jsonc", ".store.json"} + default: + return "", []string{} + } +} diff --git a/dsl/types/utils_test.go b/dsl/types/utils_test.go new file mode 100644 index 00000000..f3659e41 --- /dev/null +++ b/dsl/types/utils_test.go @@ -0,0 +1,601 @@ +package types + +import ( + "os" + "path/filepath" + "testing" +) + +func TestToPath(t *testing.T) { + tests := []struct { + name string + typ Type + id string + want string + }{ + { + name: "Model with dots and underscores", + typ: TypeModel, + id: "user__profile.admin", + want: filepath.Join("models", "user.profile", "admin.mod.yao"), + }, + { + name: "API with simple id", + typ: TypeAPI, + id: "user.login", + want: filepath.Join("apis", "user", "login.http.yao"), + }, + { + name: "Unknown type (defaults to .yao)", + typ: TypeUnknown, + id: "test", + want: filepath.Join("", "test.yao"), + }, + { + name: "Connector with nested path", + typ: TypeConnector, + id: "database.mysql__config", + want: filepath.Join("connectors", "database", "mysql.config.conn.yao"), + }, + { + name: "Type with no extensions", + typ: Type("unknown"), + id: "test", + want: filepath.Join("", "test.yao"), + }, + { + name: "Type with empty extensions", + typ: Type(""), + id: "test", + want: filepath.Join("", "test.yao"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ToPath(tt.typ, tt.id); got != tt.want { + t.Errorf("ToPath() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToID(t *testing.T) { + tests := []struct { + name string + path string + want string + }{ + { + name: "Model file path", + path: filepath.Join("models", "user.mod.yao"), + want: "user", + }, + { + name: "API file path", + path: filepath.Join("apis", "user", "login.http.yao"), + want: "user.login", + }, + { + name: "Form file path with dots", + path: filepath.Join("forms", "user.profile", "edit.form.yao"), + want: "user__profile.edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ToID(tt.path); got != tt.want { + t.Errorf("ToID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestWithTypeToID(t *testing.T) { + tests := []struct { + name string + typ Type + path string + want string + }{ + { + name: "Path with leading separator", + typ: TypeModel, + path: string(os.PathSeparator) + filepath.Join("models", "user.mod.yao"), + want: "user", + }, + { + name: "Path without leading separator", + typ: TypeModel, + path: filepath.Join("models", "user.mod.yao"), + want: "user", + }, + { + name: "Path with root not matching", + typ: TypeModel, + path: filepath.Join("other", "user.mod.yao"), + want: "other.user__mod__yao", + }, + { + name: "Nested path with dots", + typ: TypeForm, + path: filepath.Join("forms", "user.profile", "edit.form.yao"), + want: "user__profile.edit", + }, + { + name: "Multiple extensions matching", + typ: TypeModel, + path: filepath.Join("models", "user.mod.jsonc"), + want: "user", + }, + { + name: "No extension matching", + typ: TypeModel, + path: filepath.Join("models", "user.txt"), + want: "user__txt", + }, + { + name: "Path with single part", + typ: TypeModel, + path: "user.mod.yao", + want: "user__mod__yao", + }, + { + name: "Store type with multiple extensions", + typ: TypeStore, + path: filepath.Join("stores", "cache.redis.yao"), + want: "cache", + }, + { + name: "Empty path", + typ: TypeModel, + path: "", + want: "", + }, + { + name: "Path with root matching but no parts", + typ: TypeModel, + path: "models", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := WithTypeToID(tt.typ, tt.path); got != tt.want { + t.Errorf("WithTypeToID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDetectType(t *testing.T) { + tests := []struct { + name string + path string + want Type + }{ + // Test by extension + { + name: "HTTP API", + path: filepath.Join("apis", "user.http.yao"), + want: TypeAPI, + }, + { + name: "Schedule", + path: filepath.Join("schedules", "backup.sch.yao"), + want: TypeSchedule, + }, + { + name: "Table", + path: filepath.Join("tables", "user.table.yao"), + want: TypeTable, + }, + { + name: "Form", + path: filepath.Join("forms", "user.form.yao"), + want: TypeForm, + }, + { + name: "List", + path: filepath.Join("lists", "user.list.yao"), + want: TypeList, + }, + { + name: "Chart", + path: filepath.Join("charts", "sales.chart.yao"), + want: TypeChart, + }, + { + name: "Dashboard", + path: filepath.Join("dashboards", "main.dash.yao"), + want: TypeDashboard, + }, + { + name: "Flow", + path: filepath.Join("flows", "process.flow.yao"), + want: TypeFlow, + }, + { + name: "Pipe", + path: filepath.Join("pipes", "transform.pipe.yao"), + want: TypePipe, + }, + { + name: "AIGC", + path: filepath.Join("aigcs", "chat.ai.yao"), + want: TypeAIGC, + }, + { + name: "Model by extension", + path: filepath.Join("models", "user.mod.yao"), + want: TypeModel, + }, + { + name: "Connector by extension", + path: filepath.Join("connectors", "db.conn.yao"), + want: TypeConnector, + }, + { + name: "Store LRU", + path: filepath.Join("stores", "cache.lru.yao"), + want: TypeStore, + }, + { + name: "LRU extension in non-stores directory", + path: filepath.Join("other", "cache.lru.yao"), + want: TypeStore, + }, + { + name: "Store Redis", + path: filepath.Join("stores", "cache.redis.yao"), + want: TypeStore, + }, + { + name: "Store Mongo", + path: filepath.Join("stores", "cache.mongo.yao"), + want: TypeStore, + }, + { + name: "Store Badger", + path: filepath.Join("stores", "cache.badger.yao"), + want: TypeStore, + }, + { + name: "Store by extension", + path: filepath.Join("stores", "cache.store.yao"), + want: TypeStore, + }, + { + name: "MCP extension in non-apis directory", + path: filepath.Join("other", "service.mcp.yao"), + want: TypeUnknown, + }, + // Test by root path + { + name: "Model by root", + path: filepath.Join("models", "user.yao"), + want: TypeModel, + }, + { + name: "Connector by root", + path: filepath.Join("connectors", "db.yao"), + want: TypeConnector, + }, + { + name: "MCP Client", + path: filepath.Join("mcps", "client.yao"), + want: TypeMCPClient, + }, + { + name: "API by root with http ext", + path: filepath.Join("apis", "user.http.yao"), + want: TypeAPI, + }, + { + name: "MCP Server", + path: filepath.Join("apis", "server.mcp.yao"), + want: TypeMCPServer, + }, + { + name: "MCP by extension", + path: filepath.Join("mcps", "client.mcp.yao"), + want: TypeMCPClient, + }, + { + name: "API by root unknown ext", + path: filepath.Join("apis", "user.unknown.yao"), + want: TypeUnknown, + }, + { + name: "Schedule by root", + path: filepath.Join("schedules", "backup.yao"), + want: TypeSchedule, + }, + { + name: "Table by root", + path: filepath.Join("tables", "user.yao"), + want: TypeTable, + }, + { + name: "Form by root", + path: filepath.Join("forms", "user.yao"), + want: TypeForm, + }, + { + name: "List by root", + path: filepath.Join("lists", "user.yao"), + want: TypeList, + }, + { + name: "Chart by root", + path: filepath.Join("charts", "sales.yao"), + want: TypeChart, + }, + { + name: "Dashboard by root", + path: filepath.Join("dashboards", "main.yao"), + want: TypeDashboard, + }, + { + name: "Flow by root", + path: filepath.Join("flows", "process.yao"), + want: TypeFlow, + }, + { + name: "Pipe by root", + path: filepath.Join("pipes", "transform.yao"), + want: TypePipe, + }, + { + name: "AIGC by root", + path: filepath.Join("aigcs", "chat.yao"), + want: TypeAIGC, + }, + { + name: "Store by root", + path: filepath.Join("stores", "cache.yao"), + want: TypeStore, + }, + { + name: "Unknown root", + path: filepath.Join("unknown", "file.yao"), + want: TypeUnknown, + }, + // Edge cases + { + name: "Path with less than 2 parts", + path: "file.yao", + want: TypeUnknown, + }, + { + name: "File without extension", + path: filepath.Join("models", "user"), + want: TypeUnknown, + }, + { + name: "File with single dot", + path: filepath.Join("models", "user.yao"), + want: TypeModel, + }, + { + name: "Empty path", + path: "", + want: TypeUnknown, + }, + { + name: "Path with single component", + path: "file", + want: TypeUnknown, + }, + { + name: "File with extension parts length < 2", + path: filepath.Join("models", "user"), + want: TypeUnknown, + }, + { + name: "File with extension matching filename", + path: filepath.Join("models", "http.yao"), + want: TypeAPI, + }, + { + name: "File with extension matching filename - sch", + path: filepath.Join("schedules", "sch.yao"), + want: TypeSchedule, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DetectType(tt.path); got != tt.want { + t.Errorf("DetectType() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTypeRootAndExts(t *testing.T) { + tests := []struct { + name string + typ Type + wantRoot string + wantExts []string + }{ + { + name: "Model", + typ: TypeModel, + wantRoot: "models", + wantExts: []string{".mod.yao", ".mod.jsonc", ".mod.json"}, + }, + { + name: "Connector", + typ: TypeConnector, + wantRoot: "connectors", + wantExts: []string{".conn.yao", ".conn.jsonc", ".conn.json"}, + }, + { + name: "MCP Client", + typ: TypeMCPClient, + wantRoot: "mcps", + wantExts: []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"}, + }, + { + name: "MCP Server", + typ: TypeMCPServer, + wantRoot: "mcps", + wantExts: []string{".mcp.yao", ".mcp.jsonc", ".mcp.json"}, + }, + { + name: "API", + typ: TypeAPI, + wantRoot: "apis", + wantExts: []string{".http.yao", ".http.jsonc", ".http.json"}, + }, + { + name: "Schedule", + typ: TypeSchedule, + wantRoot: "schedules", + wantExts: []string{".sch.yao", ".sch.jsonc", ".sch.json"}, + }, + { + name: "Table", + typ: TypeTable, + wantRoot: "tables", + wantExts: []string{".table.yao", ".table.jsonc", ".table.json"}, + }, + { + name: "Form", + typ: TypeForm, + wantRoot: "forms", + wantExts: []string{".form.yao", ".form.jsonc", ".form.json"}, + }, + { + name: "List", + typ: TypeList, + wantRoot: "lists", + wantExts: []string{".list.yao", ".list.jsonc", ".list.json"}, + }, + { + name: "Chart", + typ: TypeChart, + wantRoot: "charts", + wantExts: []string{".chart.yao", ".chart.jsonc", ".chart.json"}, + }, + { + name: "Dashboard", + typ: TypeDashboard, + wantRoot: "dashboards", + wantExts: []string{".dash.yao", ".dash.jsonc", ".dash.json"}, + }, + { + name: "Flow", + typ: TypeFlow, + wantRoot: "flows", + wantExts: []string{".flow.yao", ".flow.jsonc", ".flow.json"}, + }, + { + name: "Pipe", + typ: TypePipe, + wantRoot: "pipes", + wantExts: []string{".pipe.yao", ".pipe.jsonc", ".pipe.json"}, + }, + { + name: "AIGC", + typ: TypeAIGC, + wantRoot: "aigcs", + wantExts: []string{".ai.yao", ".ai.jsonc", ".ai.json"}, + }, + { + name: "Store", + typ: TypeStore, + wantRoot: "stores", + wantExts: []string{".lru.yao", ".redis.yao", ".mongo.yao", ".badger.yao", ".store.yao", ".store.jsonc", ".store.json"}, + }, + { + name: "Unknown", + typ: TypeUnknown, + wantRoot: "", + wantExts: []string{}, + }, + { + name: "Empty type", + typ: Type(""), + wantRoot: "", + wantExts: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRoot, gotExts := TypeRootAndExts(tt.typ) + if gotRoot != tt.wantRoot { + t.Errorf("TypeRootAndExts() root = %v, want %v", gotRoot, tt.wantRoot) + } + if len(gotExts) != len(tt.wantExts) { + t.Errorf("TypeRootAndExts() exts length = %v, want %v", len(gotExts), len(tt.wantExts)) + return + } + for i, ext := range gotExts { + if ext != tt.wantExts[i] { + t.Errorf("TypeRootAndExts() exts[%d] = %v, want %v", i, ext, tt.wantExts[i]) + } + } + }) + } +} + +// Test integration scenarios +func TestIntegration(t *testing.T) { + tests := []struct { + name string + typ Type + id string + wantPath string + wantID string + }{ + { + name: "Model round trip", + typ: TypeModel, + id: "user__profile.admin", + wantPath: filepath.Join("models", "user.profile", "admin.mod.yao"), + wantID: "user__profile.admin", + }, + { + name: "API round trip", + typ: TypeAPI, + id: "user.login", + wantPath: filepath.Join("apis", "user", "login.http.yao"), + wantID: "user.login", + }, + { + name: "Complex nested path", + typ: TypeForm, + id: "admin__panel.user__management.edit", + wantPath: filepath.Join("forms", "admin.panel", "user.management", "edit.form.yao"), + wantID: "admin__panel.user__management.edit", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test ID to Path + path := ToPath(tt.typ, tt.id) + if path != tt.wantPath { + t.Errorf("ToPath() = %v, want %v", path, tt.wantPath) + } + + // Test Path to ID + id := WithTypeToID(tt.typ, path) + if id != tt.wantID { + t.Errorf("WithTypeToID() = %v, want %v", id, tt.wantID) + } + + // Test DetectType + detectedType := DetectType(path) + if detectedType != tt.typ { + t.Errorf("DetectType() = %v, want %v", detectedType, tt.typ) + } + }) + } +}