diff --git a/algo/topo.go b/algo/topo.go new file mode 100644 index 0000000..a52c382 --- /dev/null +++ b/algo/topo.go @@ -0,0 +1,104 @@ +package algo + +import "fmt" + +type Value interface{} + +type Graph map[Value]*Node + +type Node struct { + Value Value + graph Graph + Dependents, + Dependencies, + Optionals map[*Node]struct{} +} + + +func (n *Node) String() string { + return fmt.Sprintf("%+v", n.Value) +} + +func (n *Node) AddDependencies(keys ...Value) { + for _, key := range keys { + dep := n.graph.AddNode(key) + n.Dependencies[dep] = struct{}{} + dep.Dependents[n] = struct{}{} + } +} + +func (n *Node) AddOptionals(keys ...Value) { + for _, key := range keys { + dep := n.graph.AddNode(key) + n.Optionals[dep] = struct{}{} + } +} + +func (n *Node) IsRoot() bool { + return len(n.Dependents) == 0 +} + +func (n *Node) IsLeaf() bool { + return len(n.Dependencies) == 0 +} + + +func MakeGraph() Graph { + return make(Graph) +} + +func (g Graph) AddNode(key Value) *Node { + if g[key] == nil { + g[key] = &Node{ + Value: key, + graph: g, + Dependents: make(map[*Node]struct{}), + Dependencies: make(map[*Node]struct{}), + Optionals: make(map[*Node]struct{}), + } + } + return g[key] +} + +func (g Graph) RemoveNode(key Value) { + if g[key] == nil { + return + } + n := g[key] + for _, d := range g { + delete(d.Dependencies, n) + delete(d.Dependents, n) + delete(d.Optionals, n) + } + delete(g, key) +} + +func (g Graph) Sorted() []*Node { + sorted := make([]*Node, 0, len(g)) + degree := make(map[*Node]int) + + var next []*Node + for _, n := range g { + if n.IsRoot() { + next = append(next, n) + } else { + degree[n] = len(n.Dependents) + } + } + + for len(next) > 0 { + n := next[0] + next = next[1:] + + sorted = append(sorted, n) + + for d := range n.Dependencies { + degree[d]-- + if degree[d] == 0 { + next = append(next, d) + } + } + } + + return sorted +} diff --git a/db.go b/db.go index 4ed3d7e..604a047 100644 --- a/db.go +++ b/db.go @@ -21,15 +21,19 @@ import ( "compress/bzip2" "database/sql" "fmt" + "log" "os" "path/filepath" - "regexp" + "strconv" + "strings" "golang.org/x/text/language" "golang.org/x/text/message" _ "github.com/mattn/go-sqlite3" + + "mcdex/algo" ) type Database struct { @@ -38,6 +42,14 @@ type Database struct { version string } +type DepType int + +const ( + Required = 1 + Optional = 2 + Embedded = 3 +) + func OpenDatabase() (*Database, error) { db := new(Database) @@ -231,8 +243,8 @@ func (db *Database) getLatestFileTstamp() (int, error) { func (db *Database) getLatestModFile(modID int, mcvsn string) (*ModFile, error) { // First, look up the modid for the given name - var name, desc string - err := db.sqlDb.QueryRow("select name, description from projects where type = 0 and projectid = ?", modID).Scan(&name, &desc) + var name, slug, desc string + err := db.sqlDb.QueryRow("select name, slug, description from projects where type = 0 and projectid = ?", modID).Scan(&name, &slug, &desc) switch { case err == sql.ErrNoRows: return nil, fmt.Errorf("No mod found %d", modID) @@ -251,7 +263,7 @@ func (db *Database) getLatestModFile(modID int, mcvsn string) (*ModFile, error) return nil, err } - return &ModFile{fileID: fileID, modID: modID, modName: name, modDesc: desc}, nil + return &ModFile{fileID: fileID, modID: modID, modName: name, slug: slug, modDesc: desc}, nil } func (db *Database) findProjectBySlug(slug string, ptype int) (int, error) { @@ -285,7 +297,7 @@ func (db *Database) findModByName(name string) (int, error) { func (db *Database) findModFile(modID, fileID int, mcversion string) (*ModFile, error) { // Try to match the file ID if fileID > 0 { - err := db.sqlDb.QueryRow("select fileid from files where projectid = ? and fileid = ? and version = ?", modID, fileID, mcversion).Scan(&fileID) + err := db.sqlDb.QueryRow("select projectid from files where fileid = ? and version = ?", fileID, mcversion).Scan(&modID) if err != nil { return nil, fmt.Errorf("No matching file ID for %s version", mcversion) } @@ -298,13 +310,13 @@ func (db *Database) findModFile(modID, fileID int, mcversion string) (*ModFile, } // We matched some file; pull the name and description for the mod - var name, desc string - err := db.sqlDb.QueryRow("select slug, description from projects where projectid = ?", modID).Scan(&name, &desc) + var name, slug, desc string + err := db.sqlDb.QueryRow("select name, slug, description from projects where projectid = ?", modID).Scan(&name, &slug, &desc) if err != nil { return nil, fmt.Errorf("Failed to retrieve name, description for mod %d: %+v", modID, err) } - return &ModFile{fileID: fileID, modID: modID, modName: name, modDesc: desc}, nil + return &ModFile{fileID: fileID, modID: modID, modName: name, slug: slug, modDesc: desc}, nil } func (db *Database) getDeps(fileID int) ([]int, error) { @@ -351,6 +363,78 @@ func (db *Database) getLatestPackURL(slug string) (string, error) { } // Construct a URL using the slug and file ID - return fmt.Sprintf("https://minecraft.curseforge.com/projects/%d/files/%d/download", pid, fileID), nil; + return fmt.Sprintf("https://minecraft.curseforge.com/projects/%d/files/%d/download", pid, fileID), nil +} + +func (db *Database) buildDepGraph(m *ModPack) (algo.Graph, error) { + + var fileIds map[int]*ManifestFileEntry + var fileIdsString strings.Builder + g := algo.MakeGraph() + + // Load all files from manifest + { + nameQuery, err := db.sqlDb.Prepare("SELECT DISTINCT name FROM files f, projects p WHERE f.fileid = ? AND f.projectid = ? AND p.projectid = f.projectid") + if err != nil { + return nil, err + } + defer nameQuery.Close() + + files, _ := m.manifest.S("files").Children() + fileIds = make(map[int]*ManifestFileEntry, len(files)) + for i, file := range files { + record := ManifestFileEntry{idx: i} + + record.projId = int(file.S("projectID").Data().(float64)) + record.fileId = int(file.S("fileID").Data().(float64)) -} \ No newline at end of file + if fid, filename := m.modCache.GetLastModFile(record.projId); fid > 0 { + record.file = filename + } + + err = nameQuery.QueryRow(record.fileId, record.projId).Scan(&record.name) + switch { + case err == sql.ErrNoRows: + log.Printf("No mod found in database with project id %d and file id %d - File: %q\n\tDependency resolution may be incomplete!", record.projId, record.fileId, record.file) + case err != nil: + return nil, err + } + + fileIds[record.fileId] = &record + g.AddNode(&record) + + fileIdsString.WriteString(strconv.Itoa(record.fileId)) + fileIdsString.WriteByte(',') + } + // Simple hack to deal with trailing comma or empty + fileIdsString.WriteByte('0') + } + + // Load dependencies and add to graph + { + rows, err := db.sqlDb.Query(fmt.Sprintf("SELECT DISTINCT d.fileid, f.fileid, level FROM deps d, files f WHERE level <> 3 AND f.projectid = d.projectid AND d.fileid IN (%[1]s) AND f.fileid IN (%[1]s)", fileIdsString.String())) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var fileid, depid int + var depType DepType + if err = rows.Scan(&fileid, &depid, &depType); err != nil { + return nil, err + } + + node := g[fileIds[fileid]] + dep := fileIds[depid] + switch depType { + case Required: + node.AddDependencies(dep) + case Optional: + node.AddOptionals(dep) + } + } + } + + return g, nil +} diff --git a/main.go b/main.go index 4802cca..a0922bb 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "flag" "fmt" "log" + "math" "net/url" "os" "os/exec" @@ -32,6 +33,8 @@ import ( "time" "github.com/xeonx/timeago" + + "mcdex/algo" ) var version string @@ -74,6 +77,12 @@ var gCommands = map[string]command{ ArgsCount: 1, Args: " []", }, + "pack.show": { + Fn: cmdPackShow, + Desc: "List all mods included in the specified installed pack", + ArgsCount: 1, + Args: "", + }, "info": { Fn: cmdInfo, Desc: "Show runtime info", @@ -91,7 +100,6 @@ var gCommands = map[string]command{ ArgsCount: 0, Args: "[]", }, - "mod.select": { Fn: cmdModSelect, Desc: "Select a mod to include in the specified pack", @@ -104,6 +112,18 @@ var gCommands = map[string]command{ ArgsCount: 2, Args: " []", }, + "mod.remove.single": { + Fn: cmdModRemoveSingle, + Desc: "Remove individual mods from the specified pack, without handling dependencies", + ArgsCount: 2, + Args: " [mod names...]", + }, + "mod.remove.recursive": { + Fn: cmdModRemoveRecursive, + Desc: "Remove specified mods, and all their dependant mods, from the specified pack", + ArgsCount: 2, + Args: " [mod names...]", + }, "mod.update.all": { Fn: cmdModUpdateAll, Desc: "Update all mods entries to latest available file", @@ -262,6 +282,28 @@ func cmdPackInstall() error { return nil } +func cmdPackShow() error { + // Try to open the mod pack + cp, err := NewModPack(flag.Arg(1), true, ARG_MMC) + if err != nil { + return err + } + + db, err := OpenDatabase() + if err != nil { + return err + } + + mods, err := cp.getSelected(db) + sort.Slice(mods, func(i, j int) bool {return mods[i].name < mods[j].name}) + fmt.Println( " File ID || Project ID|| Name || Slug || Description || Released || Filename") + for _, mod := range mods { + console("%9d || %9d || %s || %s || %s || %v || %s\n", mod.fileID, mod.projectID, mod.name, mod.slug, mod.description, mod.timestamp, mod.filename) + } + + return nil +} + func cmdInfo() error { // Try to retrieve the latest available version info publishedVsn, err := readStringFromUrl("http://files.mcdex.net/release/latest") @@ -293,6 +335,289 @@ func cmdModSelectClient() error { return _modSelect(flag.Arg(1), flag.Arg(2), flag.Arg(3), true) } + +func _initRemove(cp *ModPack, modNames []string, maxDepth int) (info *DepInfo, err error) { + db, err := OpenDatabase() + if err != nil { + return + } + + // Try to lookup the mod IDs by name + modIDs := make(map[int]struct{}, len(modNames)) + for _, modName := range modNames { + modID, err := db.findModByName(modName) + if err != nil { + log.Println(err) + continue + } + + modID, err = cp.lookupFileId(modID) + if err != nil { + log.Println(err) + continue + } + + modIDs[modID] = struct{}{} + } + + if len(modIDs) == 0 { + err = fmt.Errorf("no mods found") + return + } + + return _processDeps(cp, db, modIDs, maxDepth) +} + +type DepInfo struct { + targets map[*ManifestFileEntry]struct{} + dependents map[*ManifestFileEntry][]*ManifestFileEntry + dependencies map[*ManifestFileEntry][]*ManifestFileEntry + optionals map[*ManifestFileEntry][]*ManifestFileEntry +} +func _processDeps(cp *ModPack, db *Database, targetModIds map[int]struct{}, maxDepth int) (*DepInfo, error) { + mods, err := db.buildDepGraph(cp) + if err != nil { + return nil, err + } + + if maxDepth < 0 { + maxDepth = math.MaxInt32 + } + + relatedMods := make(map[*algo.Node]int) + result := DepInfo{ + targets: make(map[*ManifestFileEntry]struct{}, len(targetModIds)), + dependents: make(map[*ManifestFileEntry][]*ManifestFileEntry), + dependencies: make(map[*ManifestFileEntry][]*ManifestFileEntry), + optionals: make(map[*ManifestFileEntry][]*ManifestFileEntry), + } + + // Find all dependents + { + deps := make([]*algo.Node, 0, len(targetModIds)) + depths := make([]int, 0, len(targetModIds)) + for _, m := range mods { + entry := m.Value.(*ManifestFileEntry) + if _, found := relatedMods[m]; found { + continue + } + if _, found := targetModIds[entry.fileId]; !found { + continue + } + + deps = append(deps, m) + depths = append(depths, 0) + result.targets[entry] = struct{}{} + } + + for len(deps) > 0 && depths[0] < maxDepth { + d := deps[0] + depth := depths[0] + deps = deps[1:] + depths = depths[1:] + + // Already included + if _, found := relatedMods[d]; found { + continue + } + + relatedMods[d] = depth + for dd := range d.Dependents { + depths = append(depths, depth+1) + deps = append(deps, dd) + r := result.dependents[dd.Value.(*ManifestFileEntry)] + result.dependents[dd.Value.(*ManifestFileEntry)] = append(r, d.Value.(*ManifestFileEntry)) + } + } + } + + // Find all dependencies that will no longer have a dependent + { + sorted := mods.Sorted() + Outer: + for _, m := range sorted { + // Only looking at dependencies here, any roots would have been included above + if m.IsRoot() { + continue + } + // Already included + if _, found := relatedMods[m]; found { + continue + } + var parents []*ManifestFileEntry + minDepth := 0 + for d := range m.Dependents { + depth, found := relatedMods[d] + if !found || depth >= maxDepth { + continue Outer // Still has a dependent + } + if (depth + 1) < minDepth { + minDepth = depth + 1 + } + parents = append(parents, d.Value.(*ManifestFileEntry)) + } + + // All dependents included + relatedMods[m] = minDepth + result.dependencies[m.Value.(*ManifestFileEntry)] = parents + } + } + + // Find all related optional dependencies + { + for _, m := range mods { + if _, found := relatedMods[m]; found { + continue + } + var opts []*ManifestFileEntry + for o := range m.Optionals { + if depth, found := relatedMods[o]; !found || depth >= maxDepth { + continue + } + + entry := o.Value.(*ManifestFileEntry) + if _, found := result.dependencies[entry]; !found { + opts = append(opts, entry) + } else { + // Remove dependency that has an optional dependent + delete(relatedMods, o) + delete(result.dependencies, entry) + } + } + if len(opts) > 0 { + result.optionals[m.Value.(*ManifestFileEntry)] = opts + } + } + } + + return &result, nil +} + +func _removeMods(cp *ModPack, mods []*ManifestFileEntry) error { + if ARG_DRY_RUN { + return nil + } + + // Reverse sort by index so we remove from the back + sort.Slice(mods, func(i, j int) bool { + return mods[j].idx < mods[i].idx + }) + + fmt.Println() + + var done int + for _, m := range mods { + fmt.Printf("Removing [%7d] %q - %q\n", m.fileId, m.name, m.file) + if err := cp.manifest.ArrayRemove(m.idx, "files"); err != nil { + log.Printf("Failed to remove mod %q from manifest\n", m.name) + continue + } + done++ + if err := cp.modCache.CleanupModFile(m.projId); err != nil { + log.Println("Warning: ", err) + } + } + if done > 0 { + if err := cp.saveManifest(); err != nil { + return fmt.Errorf("failed to save changes to manifest") + } + } else { + return fmt.Errorf("failed to remove any mods; no changes have been made") + } + if done < len(mods) { + return fmt.Errorf("some mods could not be removed; pack may be in an invalid state") + } + + return nil +} + +func cmdModRemoveSingle() error { + // Try to open the mod pack + cp, err := NewModPack(flag.Arg(1), true, ARG_MMC) + if err != nil { + return err + } + + depInfo, err := _initRemove(cp, flag.Args()[2:], 1) + if err != nil { + return err + } + + rmList := make([]*ManifestFileEntry, 0, len(depInfo.targets)) + + fmt.Println() + fmt.Println("Preparing to remove the mod(s):") + for target := range depInfo.targets { + fmt.Printf("\t[%7d] %q (%s)\n", target.fileId, target.name, target.file) + rmList = append(rmList, target) + } + + fmt.Println() + fmt.Println("The following mods depend on a mod being removed and will no longer work:") + for m, d := range depInfo.dependents { + fmt.Printf("\t[%7d] %q (%s)\n\t\tDepends on %s\n", m.fileId, m.name, m.file, QuoteJoin(d, ", ")) + } + + fmt.Println() + fmt.Println("The following mods were added as a dependency for a mod being removed and are no longer required:") + for m, d := range depInfo.dependencies { + fmt.Printf("\t[%7d] %q (%s)\n\t\tRequired by %s\n", m.fileId, m.name, m.file, QuoteJoin(d, ", ")) + } + + fmt.Println() + fmt.Println("The following mods optionally depend on a mod being removed:") + for d, o := range depInfo.optionals { + fmt.Printf("\t[%7d] %q optionally depends on %s\n", d.fileId, d.name, QuoteJoin(o, ", ")) + } + + return _removeMods(cp, rmList) +} + +func cmdModRemoveRecursive() error { + // Try to open the mod pack + cp, err := NewModPack(flag.Arg(1), true, ARG_MMC) + if err != nil { + return err + } + + depInfo, err := _initRemove(cp, flag.Args()[2:], -1) + if err != nil { + return err + } + + rmList := make([]*ManifestFileEntry, 0, len(depInfo.targets)+len(depInfo.dependencies)+len(depInfo.dependents)) + + fmt.Println() + fmt.Println("Preparing to remove the mod(s):") + for target := range depInfo.targets { + fmt.Printf("\t[%7d] %q (%s)\n", target.fileId, target.name, target.file) + rmList = append(rmList, target) + } + + fmt.Println() + fmt.Println("The following dependent mods will also be removed:") + for m, d := range depInfo.dependents { + fmt.Printf("\t[%7d] %q (%s)\n\t\tDepends on %s\n", m.fileId, m.name, m.file, QuoteJoin(d,", ")) + rmList = append(rmList, m) + } + + fmt.Println() + fmt.Println("The following dependencies will also be removed:") + for m, d := range depInfo.dependencies { + fmt.Printf("\t[%7d] %q (%s)\n\t\tRequired by %s\n", m.fileId, m.name, m.file, QuoteJoin(d, ", ")) + rmList = append(rmList, m) + } + + fmt.Println() + fmt.Println("The following mods optionally depend on a mod being removed:") + for d, o := range depInfo.optionals { + fmt.Printf("\t[%7d] %q optionally depends on %s\n", d.fileId, d.name, QuoteJoin(o, ", ")) + } + + return _removeMods(cp, rmList) +} + + var curseForgeRegex = regexp.MustCompile("/projects/([\\w-]*)(/files/(\\d+))?") func _modSelect(dir, mod, tag string, clientOnly bool) error { @@ -342,7 +667,7 @@ func _modSelect(dir, mod, tag string, clientOnly bool) error { } err = _selectModFromID(cp, db, modID, fileID, clientOnly) - if err == nil { + if err == nil && !ARG_DRY_RUN { return cp.saveManifest() } @@ -350,13 +675,38 @@ func _modSelect(dir, mod, tag string, clientOnly bool) error { } func _selectModFromID(pack *ModPack, db *Database, modID, fileID int, clientOnly bool) error { + if modFile, err := _lookupModByID(pack, db, modID, fileID); err == nil { + err := pack.selectModFile(modFile, clientOnly) + if err != nil { + return err + } + + deps, err := db.getDeps(modFile.fileID) + if err != nil { + return fmt.Errorf("Error pulling deps for %d: %+v", modFile.fileID, err) + } + + for _, dep := range deps { + err = _selectModFromID(pack, db, dep, 0, clientOnly) + if err != nil { + return err + } + } + + return nil + } else { + return err + } +} + +func _lookupModByID(pack *ModPack, db *Database, modID, fileID int) (*ModFile, error) { // At this point, we should have a modID and we may have a fileID. We want to walk major.minor.[patch] // versions, and find either the latest file for our version of minecraft or verify that the fileID // we have will work on this version major, minor, patch, err := parseVersion(pack.minecraftVersion()) if err != nil { // Invalid version string?! - return err + return nil, err } // Walk down patch versions, looking for our mod + file (or latest file if no fileID available) @@ -368,31 +718,12 @@ func _selectModFromID(pack *ModPack, db *Database, modID, fileID int, clientOnly vsn = fmt.Sprintf("%d.%d", major, minor) } - modFile, err := db.findModFile(modID, fileID, vsn) - if err == nil { - err := pack.selectModFile(modFile, clientOnly) - if err != nil { - return err - } - - deps, err := db.getDeps(modFile.fileID) - if err != nil { - return fmt.Errorf("Error pulling deps for %d: %+v", modFile.fileID, err) - } - - for _, dep := range deps { - err = _selectModFromID(pack, db, dep, 0, clientOnly) - if err != nil { - return err - } - } - - return nil + if modFile, err := db.findModFile(modID, fileID, vsn); err == nil { + return modFile, nil } } - // Didn't find a file that matches :( - return fmt.Errorf("No compatible file found for %d\n", modID) + return nil, fmt.Errorf("No compatible file found for %d\n", modID) } func listProjects(ptype int) error { @@ -687,6 +1018,10 @@ func main() { os.Exit(-1) } + if ARG_DRY_RUN { + fmt.Printf("--- DRY RUN ---\n") + } + err = command.Fn() if err != nil { log.Fatalf("%+v\n", err) diff --git a/mod.go b/mod.go index 8e1562d..5067269 100644 --- a/mod.go +++ b/mod.go @@ -17,9 +17,35 @@ package main +import "time" + type ModFile struct { fileID int modID int modName string modDesc string + slug string +} + +type ManifestFileEntry struct { + idx int + file string + + fileId int + projId int + name string +} + +func (m *ManifestFileEntry) String() string { + return m.name +} + +type ModDetails struct { + fileID int + projectID int + name string + slug string + description string + filename string + timestamp time.Time } diff --git a/modpack.go b/modpack.go index 14fffbf..5a6e40c 100644 --- a/modpack.go +++ b/modpack.go @@ -19,12 +19,14 @@ package main import ( "archive/zip" + "database/sql" "fmt" + "io/ioutil" + "log" "os" "path/filepath" "strings" - - "io/ioutil" + "time" "github.com/Jeffail/gabs" ) @@ -362,7 +364,7 @@ func (pack *ModPack) selectModFile(modFile *ModFile, clientOnly bool) error { modInfo["projectID"] = modFile.modID modInfo["fileID"] = modFile.fileID modInfo["required"] = true - modInfo["desc"] = modFile.modName + modInfo["desc"] = modFile.slug if clientOnly { modInfo["clientOnly"] = true @@ -594,3 +596,49 @@ func (pack *ModPack) installServer() error { func (pack *ModPack) generateMMCConfig() error { return generateMMCConfig(pack) } + +func (pack *ModPack) lookupFileId(projectId int) (int, error) { + files, _ := pack.manifest.S("files").Children() + for _, file := range files { + if projectId == int(file.S("projectID").Data().(float64)) { + return int(file.S("fileID").Data().(float64)), nil + } + } + + return -1, fmt.Errorf("project %d not found in manifest", projectId) +} + +func (pack *ModPack) getSelected(db *Database) ([]*ModDetails, error) { + detailQuery, err := db.sqlDb.Prepare("SELECT DISTINCT name, slug, description, f.tstamp FROM files f, projects p WHERE f.fileid = ? AND f.projectid = ? AND p.projectid = f.projectid") + if err != nil { + return nil, err + } + defer detailQuery.Close() + + files, _ := pack.manifest.S("files").Children() + results := make([]*ModDetails, 0, len(files)) + for _, file := range files { + var record ModDetails + + record.projectID = int(file.S("projectID").Data().(float64)) + record.fileID = int(file.S("fileID").Data().(float64)) + + if fid, filename := pack.modCache.GetLastModFile(record.projectID); fid > 0 { + record.filename = filename + } + + var ts int64 + err = detailQuery.QueryRow(record.fileID, record.projectID).Scan(&record.name, &record.slug, &record.description, &ts) + switch { + case err == sql.ErrNoRows: + log.Printf("No mod found in database with project id %d and file id %d - File: %q", record.projectID, record.fileID, record.filename) + case err != nil: + return nil, err + } + record.timestamp = time.Unix(ts, 0) + + results = append(results, &record) + } + + return results, nil +} \ No newline at end of file diff --git a/util.go b/util.go index bac84d7..5f0cfca 100644 --- a/util.go +++ b/util.go @@ -27,6 +27,7 @@ import ( "net" "net/http" "os" + "reflect" "strconv" "strings" "time" @@ -260,3 +261,15 @@ func intValue(c *gabs.Container, path string) (int, error) { return 0, fmt.Errorf("Invalid type for %s: %+v", path, data) } } + +func QuoteJoin(slice interface{}, sep string) string { + s := reflect.ValueOf(slice) + if s.Kind() != reflect.Slice { + panic(&reflect.ValueError{Method: "QuoteJoin", Kind: s.Kind()}) + } + str := make([]string, s.Len()) + for i := 0; i < len(str); i++ { + str[i] = fmt.Sprintf("%q", s.Index(i)) + } + return strings.Join(str, sep) +} \ No newline at end of file